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);
|
||||
}
|
||||
|
||||
throw new Error("Unknown filter element");
|
||||
throw new Error(`Unknown filter element: "${rowType}"`);
|
||||
};
|
||||
|
||||
export const useProductFilterAPIProvider = (): FilterAPIProvider => {
|
||||
|
|
|
@ -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<ErrorEntry[]>([])
|
||||
|
||||
const handleConfirm = (value: FilterContainer) => {
|
||||
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}
|
||||
>
|
||||
<FiltersArea onConfirm={handleConfirm} onCancel={handleCancel} />
|
||||
<FiltersArea onConfirm={handleConfirm} errors={errors} onCancel={handleCancel} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -74,6 +74,10 @@ export class ConditionOptions extends Array<ConditionItem> {
|
|||
return new ConditionOptions([]);
|
||||
}
|
||||
|
||||
public isEmpty() {
|
||||
return this.length === 0
|
||||
}
|
||||
|
||||
public findByLabel(label: string) {
|
||||
return this.find(f => f.label === label);
|
||||
}
|
||||
|
|
|
@ -10,6 +10,10 @@ export class ConditionSelected {
|
|||
public loading: boolean,
|
||||
) {}
|
||||
|
||||
public isEmpty() {
|
||||
return this.value === ""
|
||||
}
|
||||
|
||||
public static empty() {
|
||||
return new ConditionSelected("", null, [], false);
|
||||
}
|
||||
|
|
|
@ -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<string | FilterElement | FilterContainer>;
|
||||
|
|
|
@ -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<FiltersAreaProps> = ({ onConfirm, onCancel }) => {
|
||||
export const FiltersArea: FC<FiltersAreaProps> = ({ onConfirm, onCancel, errors }) => {
|
||||
const { apiProvider, leftOperandsProvider } = useConditionalFilterContext();
|
||||
|
||||
const {
|
||||
value,
|
||||
hasEmptyRows,
|
||||
addEmpty,
|
||||
removeAt,
|
||||
updateLeftOperator,
|
||||
|
@ -67,6 +70,7 @@ export const FiltersArea: FC<FiltersAreaProps> = ({ onConfirm, onCancel }) => {
|
|||
// @ts-expect-error
|
||||
value={value}
|
||||
onChange={handleStateChange}
|
||||
error={errors}
|
||||
>
|
||||
<_ExperimentalFilters.Footer>
|
||||
<_ExperimentalFilters.AddRowButton>
|
||||
|
@ -76,7 +80,7 @@ export const FiltersArea: FC<FiltersAreaProps> = ({ onConfirm, onCancel }) => {
|
|||
<_ExperimentalFilters.ClearButton onClick={onCancel}>
|
||||
Clear
|
||||
</_ExperimentalFilters.ClearButton>
|
||||
<_ExperimentalFilters.ConfirmButton onClick={() => onConfirm(value)}>
|
||||
<_ExperimentalFilters.ConfirmButton onClick={() => onConfirm(value)} disabled={hasEmptyRows}>
|
||||
Save
|
||||
</_ExperimentalFilters.ConfirmButton>
|
||||
</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 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,
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue