diff --git a/src/components/ConditionalFilter/API/ProductFilterAPIProvider.tsx b/src/components/ConditionalFilter/API/ProductFilterAPIProvider.tsx index 8f4f7ce7d..38548ebc0 100644 --- a/src/components/ConditionalFilter/API/ProductFilterAPIProvider.tsx +++ b/src/components/ConditionalFilter/API/ProductFilterAPIProvider.tsx @@ -105,7 +105,7 @@ const createAPIHandler = ( return new ChannelHandler(client, inputValue); } - throw new Error("Unknown filter element"); + throw new Error(`Unknown filter element: "${rowType}"`); }; export const useProductFilterAPIProvider = (): FilterAPIProvider => { diff --git a/src/components/ConditionalFilter/ConditionalFilters.tsx b/src/components/ConditionalFilter/ConditionalFilters.tsx index 3fac08836..b670d9e1c 100644 --- a/src/components/ConditionalFilter/ConditionalFilters.tsx +++ b/src/components/ConditionalFilter/ConditionalFilters.tsx @@ -1,19 +1,28 @@ import { Box } from "@saleor/macaw-ui/next"; -import React, { FC } from "react"; +import React, { FC, useState } from "react"; import { useConditionalFilterContext } from "./context"; import { FilterContainer } from "./FilterElement"; import { FiltersArea } from "./FiltersArea"; import { LoadingFiltersArea } from "./LoadingFiltersArea"; +import { ErrorEntry, Validator } from "./Validation"; export const ConditionalFilters: FC<{ onClose: () => void }> = ({ onClose, }) => { const { valueProvider, containerState } = useConditionalFilterContext(); + const [errors, setErrors] = useState([]) const handleConfirm = (value: FilterContainer) => { - valueProvider.persist(value); - onClose(); + const validator = new Validator(value) + + if (validator.isValid()) { + valueProvider.persist(value); + onClose(); + return + } + + setErrors(validator.getErrors()) }; const handleCancel = () => { @@ -31,7 +40,7 @@ export const ConditionalFilters: FC<{ onClose: () => void }> = ({ borderBottomLeftRadius={2} borderBottomRightRadius={2} > - + ); }; diff --git a/src/components/ConditionalFilter/FilterElement/Condition.ts b/src/components/ConditionalFilter/FilterElement/Condition.ts index 3fcd97472..2eda6d1df 100644 --- a/src/components/ConditionalFilter/FilterElement/Condition.ts +++ b/src/components/ConditionalFilter/FilterElement/Condition.ts @@ -25,6 +25,10 @@ export class Condition { return this.loading; } + public isEmpty() { + return this.options.isEmpty() || this.selected.isEmpty() + } + public static createEmpty() { return new Condition( ConditionOptions.empty(), diff --git a/src/components/ConditionalFilter/FilterElement/ConditionOptions.ts b/src/components/ConditionalFilter/FilterElement/ConditionOptions.ts index 6c8cab4b2..3d9a8a8df 100644 --- a/src/components/ConditionalFilter/FilterElement/ConditionOptions.ts +++ b/src/components/ConditionalFilter/FilterElement/ConditionOptions.ts @@ -74,6 +74,10 @@ export class ConditionOptions extends Array { return new ConditionOptions([]); } + public isEmpty() { + return this.length === 0 + } + public findByLabel(label: string) { return this.find(f => f.label === label); } diff --git a/src/components/ConditionalFilter/FilterElement/ConditionSelected.ts b/src/components/ConditionalFilter/FilterElement/ConditionSelected.ts index 88236c623..85d133082 100644 --- a/src/components/ConditionalFilter/FilterElement/ConditionSelected.ts +++ b/src/components/ConditionalFilter/FilterElement/ConditionSelected.ts @@ -10,6 +10,10 @@ export class ConditionSelected { public loading: boolean, ) {} + public isEmpty() { + return this.value === "" + } + public static empty() { return new ConditionSelected("", null, [], false); } diff --git a/src/components/ConditionalFilter/FilterElement/FilterElement.ts b/src/components/ConditionalFilter/FilterElement/FilterElement.ts index baa6d70aa..47d87b1ed 100644 --- a/src/components/ConditionalFilter/FilterElement/FilterElement.ts +++ b/src/components/ConditionalFilter/FilterElement/FilterElement.ts @@ -14,9 +14,13 @@ class ExpressionValue { public value: string, public label: string, public type: string, - ) {} + ) { } - public static fromSlug (slug: string) { + public isEmpty() { + return this.value.length === 0 || this.label.length === 0 + } + + public static fromSlug(slug: string) { const option = STATIC_OPTIONS.find(o => o.slug === slug) if (!option) return ExpressionValue.emptyStatic() @@ -122,7 +126,7 @@ export class FilterElement { } public isEmpty() { - return this.value.type === "e"; + return this.value.isEmpty() || this.condition.isEmpty() } public isStatic() { @@ -145,11 +149,11 @@ export class FilterElement { return null; } - public setConstraint (constraint: Constraint) { + public setConstraint(constraint: Constraint) { this.constraint = constraint } - public clearConstraint () { + public clearConstraint() { this.constraint = undefined } @@ -163,7 +167,10 @@ export class FilterElement { } public static isCompatible(element: unknown): element is FilterElement { - return typeof element !== "string" && !Array.isArray(element) + return typeof element === "object" && + !Array.isArray(element) && + element !== null && + 'value' in element } public static fromValueEntry(valueEntry: any) { @@ -178,7 +185,7 @@ export class FilterElement { ); } - public static createStaticBySlug (slug: StaticElementName) { + public static createStaticBySlug(slug: StaticElementName) { return new FilterElement( ExpressionValue.fromSlug(slug), Condition.emptyFromSlug(slug), @@ -206,4 +213,10 @@ export class FilterElement { } } +export const hasEmptyRows = (container: FilterContainer) => { + return container + .filter(FilterElement.isCompatible) + .some((e: FilterElement) => e.isEmpty()) +} + export type FilterContainer = Array; diff --git a/src/components/ConditionalFilter/FiltersArea.tsx b/src/components/ConditionalFilter/FiltersArea.tsx index 97ae72988..28b2568b4 100644 --- a/src/components/ConditionalFilter/FiltersArea.tsx +++ b/src/components/ConditionalFilter/FiltersArea.tsx @@ -5,17 +5,20 @@ import { useConditionalFilterContext } from "./context"; import { FilterContainer } from "./FilterElement"; import { LeftOperand } from "./LeftOperandsProvider"; import { useFilterContainer } from "./useFilterContainer"; +import { ErrorEntry } from "./Validation"; interface FiltersAreaProps { onConfirm: (value: FilterContainer) => void; + errors?: ErrorEntry[] onCancel?: () => void; } -export const FiltersArea: FC = ({ onConfirm, onCancel }) => { +export const FiltersArea: FC = ({ onConfirm, onCancel, errors }) => { const { apiProvider, leftOperandsProvider } = useConditionalFilterContext(); const { value, + hasEmptyRows, addEmpty, removeAt, updateLeftOperator, @@ -67,6 +70,7 @@ export const FiltersArea: FC = ({ onConfirm, onCancel }) => { // @ts-expect-error value={value} onChange={handleStateChange} + error={errors} > <_ExperimentalFilters.Footer> <_ExperimentalFilters.AddRowButton> @@ -76,7 +80,7 @@ export const FiltersArea: FC = ({ onConfirm, onCancel }) => { <_ExperimentalFilters.ClearButton onClick={onCancel}> Clear - <_ExperimentalFilters.ConfirmButton onClick={() => onConfirm(value)}> + <_ExperimentalFilters.ConfirmButton onClick={() => onConfirm(value)} disabled={hasEmptyRows}> Save diff --git a/src/components/ConditionalFilter/Validation/index.ts b/src/components/ConditionalFilter/Validation/index.ts new file mode 100644 index 000000000..5447602d1 --- /dev/null +++ b/src/components/ConditionalFilter/Validation/index.ts @@ -0,0 +1,58 @@ +import { FilterContainer } from "../FilterElement"; +import { FilterElement } from "../FilterElement/FilterElement"; +import { numeric } from "./numeric"; + +const VALIDATORS = { + NUMERIC: numeric, + price: numeric +} as Record + + +const toValidated = (element: string | FilterElement | FilterContainer, index: number): RawValidateEntry => { + if (!FilterElement.isCompatible(element)) return false + + const key = element.isAttribute() ? + element.value.type : + element.value.value + + const validateFn = VALIDATORS[key as keyof typeof VALIDATORS] + + if (validateFn) { + return validateFn(element, index) + } + + return false +} + +const hasErrors = (element: RawValidateEntry): element is ErrorEntry => { + return element !== false +} + +export interface ErrorEntry { + row: number + leftText?: string; + conditionText?: string; + rightText?: string; +} + +type RawValidateEntry = false | ErrorEntry +type ValidateFn = (element: FilterElement, row: number) => RawValidateEntry + + +export class Validator { + constructor( + public container: FilterContainer + ) { } + + public isValid() { + return !this.validate().some(hasErrors) + } + + public getErrors() { + return this.validate().filter(hasErrors) + } + + private validate() { + return this.container.map(toValidated) + } +} \ No newline at end of file diff --git a/src/components/ConditionalFilter/Validation/numeric.ts b/src/components/ConditionalFilter/Validation/numeric.ts new file mode 100644 index 000000000..4defa2c5d --- /dev/null +++ b/src/components/ConditionalFilter/Validation/numeric.ts @@ -0,0 +1,41 @@ +import { FilterElement } from "../FilterElement" + +const isTooLong = (value: string, row: number) => { + if (value.length > 25) { + return { + row, + rightText: "The value is too long." + } + } + + return false +} + +export const numeric = (element: FilterElement, row: number) => { + const { value } = element.condition.selected + + if (Array.isArray(value) && value.length === 2) { + const [sLte, sGte] = value as [string, string] + const errorsLte = isTooLong(sLte, row) + const errorsGte = isTooLong(sLte, row) + + if (errorsLte) return errorsGte + if (errorsGte) return errorsGte + + const lte = parseFloat(sLte) + const gte = parseFloat(sGte) + + if (lte > gte) { + return { + row, + rightText: "The value must be higher" + } + } + } + + if (typeof value === "string") { + return isTooLong(value, row) + } + + return false +} \ No newline at end of file diff --git a/src/components/ConditionalFilter/useContainerState.ts b/src/components/ConditionalFilter/useContainerState.ts index 2e5cbb217..cf4cc3506 100644 --- a/src/components/ConditionalFilter/useContainerState.ts +++ b/src/components/ConditionalFilter/useContainerState.ts @@ -6,12 +6,9 @@ import { FilterValueProvider } from "./FilterValueProvider"; type StateCallback = (el: FilterElement) => void; type Element = FilterContainer[number]; -const isFilterElement = (el: unknown): el is FilterElement => - typeof el !== "string" && !Array.isArray(el); - const removeConstraint = (container: FilterContainer) => { return container.map(el => { - if (!isFilterElement(el)) return el; + if (!FilterElement.isCompatible(el)) return el; if (!el.constraint?.existIn(container)) { el.clearConstraint(); @@ -66,7 +63,7 @@ export const useContainerState = (valueProvider: FilterValueProvider) => { index: number, el: Element, ): el is FilterElement => { - return elIndex === index && isFilterElement(el); + return elIndex === index && FilterElement.isCompatible(el); }; const updateFilterElement = @@ -86,7 +83,7 @@ export const useContainerState = (valueProvider: FilterValueProvider) => { const updateBySlug = (slug: string, cb: StateCallback) => { setValue(v => v.map(el => { - if (isFilterElement(el) && el.value.value === slug) { + if (FilterElement.isCompatible(el) && el.value.value === slug) { cb(el); } @@ -115,7 +112,7 @@ export const useContainerState = (valueProvider: FilterValueProvider) => { const exist = (slug: string) => { return value.some( - entry => isFilterElement(entry) && entry.value.value === slug, + entry => FilterElement.isCompatible(entry) && entry.value.value === slug, ); }; diff --git a/src/components/ConditionalFilter/useFilterContainer.ts b/src/components/ConditionalFilter/useFilterContainer.ts index 527fbf1c1..df9ed9648 100644 --- a/src/components/ConditionalFilter/useFilterContainer.ts +++ b/src/components/ConditionalFilter/useFilterContainer.ts @@ -5,6 +5,7 @@ import { useConditionalFilterContext } from "./context"; import { FilterElement } from "./FilterElement"; import { ConditionValue, ItemOption } from "./FilterElement/ConditionValue"; import { Constraint } from "./FilterElement/Constraint"; +import { hasEmptyRows } from "./FilterElement/FilterElement"; import { LeftOperand, LeftOperandsProvider } from "./LeftOperandsProvider"; export const useFilterContainer = ( @@ -85,6 +86,7 @@ export const useFilterContainer = ( return { value, + hasEmptyRows: hasEmptyRows(value), addEmpty, removeAt, updateLeftOperator,