Validators for filters (#3976)

* Fix state triggering for filters

* Validation for filters

* Update types

* Change isFilterElement

* Change isFilterElement
This commit is contained in:
Patryk Andrzejewski 2023-07-25 15:56:10 +02:00 committed by GitHub
parent 8592c6a4dc
commit 48e758e843
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 157 additions and 21 deletions

View file

@ -105,7 +105,7 @@ const createAPIHandler = (
return new ChannelHandler(client, inputValue); return new ChannelHandler(client, inputValue);
} }
throw new Error("Unknown filter element"); throw new Error(`Unknown filter element: "${rowType}"`);
}; };
export const useProductFilterAPIProvider = (): FilterAPIProvider => { export const useProductFilterAPIProvider = (): FilterAPIProvider => {

View file

@ -1,19 +1,28 @@
import { Box } from "@saleor/macaw-ui/next"; import { Box } from "@saleor/macaw-ui/next";
import React, { FC } from "react"; import React, { FC, useState } from "react";
import { useConditionalFilterContext } from "./context"; import { useConditionalFilterContext } from "./context";
import { FilterContainer } from "./FilterElement"; import { FilterContainer } from "./FilterElement";
import { FiltersArea } from "./FiltersArea"; import { FiltersArea } from "./FiltersArea";
import { LoadingFiltersArea } from "./LoadingFiltersArea"; import { LoadingFiltersArea } from "./LoadingFiltersArea";
import { ErrorEntry, Validator } from "./Validation";
export const ConditionalFilters: FC<{ onClose: () => void }> = ({ export const ConditionalFilters: FC<{ onClose: () => void }> = ({
onClose, onClose,
}) => { }) => {
const { valueProvider, containerState } = useConditionalFilterContext(); const { valueProvider, containerState } = useConditionalFilterContext();
const [errors, setErrors] = useState<ErrorEntry[]>([])
const handleConfirm = (value: FilterContainer) => { const handleConfirm = (value: FilterContainer) => {
valueProvider.persist(value); const validator = new Validator(value)
onClose();
if (validator.isValid()) {
valueProvider.persist(value);
onClose();
return
}
setErrors(validator.getErrors())
}; };
const handleCancel = () => { const handleCancel = () => {
@ -31,7 +40,7 @@ export const ConditionalFilters: FC<{ onClose: () => void }> = ({
borderBottomLeftRadius={2} borderBottomLeftRadius={2}
borderBottomRightRadius={2} borderBottomRightRadius={2}
> >
<FiltersArea onConfirm={handleConfirm} onCancel={handleCancel} /> <FiltersArea onConfirm={handleConfirm} errors={errors} onCancel={handleCancel} />
</Box> </Box>
); );
}; };

View file

@ -25,6 +25,10 @@ export class Condition {
return this.loading; return this.loading;
} }
public isEmpty() {
return this.options.isEmpty() || this.selected.isEmpty()
}
public static createEmpty() { public static createEmpty() {
return new Condition( return new Condition(
ConditionOptions.empty(), ConditionOptions.empty(),

View file

@ -74,6 +74,10 @@ export class ConditionOptions extends Array<ConditionItem> {
return new ConditionOptions([]); return new ConditionOptions([]);
} }
public isEmpty() {
return this.length === 0
}
public findByLabel(label: string) { public findByLabel(label: string) {
return this.find(f => f.label === label); return this.find(f => f.label === label);
} }

View file

@ -10,6 +10,10 @@ export class ConditionSelected {
public loading: boolean, public loading: boolean,
) {} ) {}
public isEmpty() {
return this.value === ""
}
public static empty() { public static empty() {
return new ConditionSelected("", null, [], false); return new ConditionSelected("", null, [], false);
} }

View file

@ -14,9 +14,13 @@ class ExpressionValue {
public value: string, public value: string,
public label: string, public label: string,
public type: 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) const option = STATIC_OPTIONS.find(o => o.slug === slug)
if (!option) return ExpressionValue.emptyStatic() if (!option) return ExpressionValue.emptyStatic()
@ -122,7 +126,7 @@ export class FilterElement {
} }
public isEmpty() { public isEmpty() {
return this.value.type === "e"; return this.value.isEmpty() || this.condition.isEmpty()
} }
public isStatic() { public isStatic() {
@ -145,11 +149,11 @@ export class FilterElement {
return null; return null;
} }
public setConstraint (constraint: Constraint) { public setConstraint(constraint: Constraint) {
this.constraint = constraint this.constraint = constraint
} }
public clearConstraint () { public clearConstraint() {
this.constraint = undefined this.constraint = undefined
} }
@ -163,7 +167,10 @@ export class FilterElement {
} }
public static isCompatible(element: unknown): element is 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) { 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( return new FilterElement(
ExpressionValue.fromSlug(slug), ExpressionValue.fromSlug(slug),
Condition.emptyFromSlug(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<string | FilterElement | FilterContainer>; export type FilterContainer = Array<string | FilterElement | FilterContainer>;

View file

@ -5,17 +5,20 @@ import { useConditionalFilterContext } from "./context";
import { FilterContainer } from "./FilterElement"; import { FilterContainer } from "./FilterElement";
import { LeftOperand } from "./LeftOperandsProvider"; import { LeftOperand } from "./LeftOperandsProvider";
import { useFilterContainer } from "./useFilterContainer"; import { useFilterContainer } from "./useFilterContainer";
import { ErrorEntry } from "./Validation";
interface FiltersAreaProps { interface FiltersAreaProps {
onConfirm: (value: FilterContainer) => void; onConfirm: (value: FilterContainer) => void;
errors?: ErrorEntry[]
onCancel?: () => void; onCancel?: () => void;
} }
export const FiltersArea: FC<FiltersAreaProps> = ({ onConfirm, onCancel }) => { export const FiltersArea: FC<FiltersAreaProps> = ({ onConfirm, onCancel, errors }) => {
const { apiProvider, leftOperandsProvider } = useConditionalFilterContext(); const { apiProvider, leftOperandsProvider } = useConditionalFilterContext();
const { const {
value, value,
hasEmptyRows,
addEmpty, addEmpty,
removeAt, removeAt,
updateLeftOperator, updateLeftOperator,
@ -67,6 +70,7 @@ export const FiltersArea: FC<FiltersAreaProps> = ({ onConfirm, onCancel }) => {
// @ts-expect-error // @ts-expect-error
value={value} value={value}
onChange={handleStateChange} onChange={handleStateChange}
error={errors}
> >
<_ExperimentalFilters.Footer> <_ExperimentalFilters.Footer>
<_ExperimentalFilters.AddRowButton> <_ExperimentalFilters.AddRowButton>
@ -76,7 +80,7 @@ export const FiltersArea: FC<FiltersAreaProps> = ({ onConfirm, onCancel }) => {
<_ExperimentalFilters.ClearButton onClick={onCancel}> <_ExperimentalFilters.ClearButton onClick={onCancel}>
Clear Clear
</_ExperimentalFilters.ClearButton> </_ExperimentalFilters.ClearButton>
<_ExperimentalFilters.ConfirmButton onClick={() => onConfirm(value)}> <_ExperimentalFilters.ConfirmButton onClick={() => onConfirm(value)} disabled={hasEmptyRows}>
Save Save
</_ExperimentalFilters.ConfirmButton> </_ExperimentalFilters.ConfirmButton>
</Box> </Box>

View file

@ -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<string, ValidateFn>
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)
}
}

View file

@ -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
}

View file

@ -6,12 +6,9 @@ import { FilterValueProvider } from "./FilterValueProvider";
type StateCallback = (el: FilterElement) => void; type StateCallback = (el: FilterElement) => void;
type Element = FilterContainer[number]; type Element = FilterContainer[number];
const isFilterElement = (el: unknown): el is FilterElement =>
typeof el !== "string" && !Array.isArray(el);
const removeConstraint = (container: FilterContainer) => { const removeConstraint = (container: FilterContainer) => {
return container.map(el => { return container.map(el => {
if (!isFilterElement(el)) return el; if (!FilterElement.isCompatible(el)) return el;
if (!el.constraint?.existIn(container)) { if (!el.constraint?.existIn(container)) {
el.clearConstraint(); el.clearConstraint();
@ -66,7 +63,7 @@ export const useContainerState = (valueProvider: FilterValueProvider) => {
index: number, index: number,
el: Element, el: Element,
): el is FilterElement => { ): el is FilterElement => {
return elIndex === index && isFilterElement(el); return elIndex === index && FilterElement.isCompatible(el);
}; };
const updateFilterElement = const updateFilterElement =
@ -86,7 +83,7 @@ export const useContainerState = (valueProvider: FilterValueProvider) => {
const updateBySlug = (slug: string, cb: StateCallback) => { const updateBySlug = (slug: string, cb: StateCallback) => {
setValue(v => setValue(v =>
v.map(el => { v.map(el => {
if (isFilterElement(el) && el.value.value === slug) { if (FilterElement.isCompatible(el) && el.value.value === slug) {
cb(el); cb(el);
} }
@ -115,7 +112,7 @@ export const useContainerState = (valueProvider: FilterValueProvider) => {
const exist = (slug: string) => { const exist = (slug: string) => {
return value.some( return value.some(
entry => isFilterElement(entry) && entry.value.value === slug, entry => FilterElement.isCompatible(entry) && entry.value.value === slug,
); );
}; };

View file

@ -5,6 +5,7 @@ import { useConditionalFilterContext } from "./context";
import { FilterElement } from "./FilterElement"; import { FilterElement } from "./FilterElement";
import { ConditionValue, ItemOption } from "./FilterElement/ConditionValue"; import { ConditionValue, ItemOption } from "./FilterElement/ConditionValue";
import { Constraint } from "./FilterElement/Constraint"; import { Constraint } from "./FilterElement/Constraint";
import { hasEmptyRows } from "./FilterElement/FilterElement";
import { LeftOperand, LeftOperandsProvider } from "./LeftOperandsProvider"; import { LeftOperand, LeftOperandsProvider } from "./LeftOperandsProvider";
export const useFilterContainer = ( export const useFilterContainer = (
@ -85,6 +86,7 @@ export const useFilterContainer = (
return { return {
value, value,
hasEmptyRows: hasEmptyRows(value),
addEmpty, addEmpty,
removeAt, removeAt,
updateLeftOperator, updateLeftOperator,