Validators for filters (#3976)
* Fix state triggering for filters * Validation for filters * Update types * Change isFilterElement * Change isFilterElement
This commit is contained in:
parent
8592c6a4dc
commit
48e758e843
11 changed files with 157 additions and 21 deletions
|
@ -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 => {
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
58
src/components/ConditionalFilter/Validation/index.ts
Normal file
58
src/components/ConditionalFilter/Validation/index.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
41
src/components/ConditionalFilter/Validation/numeric.ts
Normal file
41
src/components/ConditionalFilter/Validation/numeric.ts
Normal 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
|
||||||
|
}
|
|
@ -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,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in a new issue