From 23bb5976c651f0575526b1bc23e24968cc0ced19 Mon Sep 17 00:00:00 2001 From: Patryk Andrzejewski Date: Thu, 20 Jul 2023 16:53:00 +0200 Subject: [PATCH] Dependencies between rows in filtering (#3956) * Fields constraint * Keep constraing on the channel level * Keep constraing on the channel level --- .changeset/smooth-pumas-judge.md | 5 ++ .../FilterElement/Condition.ts | 12 ++- .../FilterElement/Constraint.ts | 43 +++++++++ .../FilterElement/FilterElement.ts | 45 +++++++++- .../ConditionalFilter/FiltersArea.tsx | 3 +- src/components/ConditionalFilter/constants.ts | 9 ++ .../ConditionalFilter/useContainerState.ts | 87 ++++++++++++++++--- .../ConditionalFilter/useFilterContainer.ts | 23 ++++- 8 files changed, 206 insertions(+), 21 deletions(-) create mode 100644 .changeset/smooth-pumas-judge.md create mode 100644 src/components/ConditionalFilter/FilterElement/Constraint.ts diff --git a/.changeset/smooth-pumas-judge.md b/.changeset/smooth-pumas-judge.md new file mode 100644 index 000000000..6d43de042 --- /dev/null +++ b/.changeset/smooth-pumas-judge.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": minor +--- + +Constrains implementation for filter rows diff --git a/src/components/ConditionalFilter/FilterElement/Condition.ts b/src/components/ConditionalFilter/FilterElement/Condition.ts index ae9afaace..3fcd97472 100644 --- a/src/components/ConditionalFilter/FilterElement/Condition.ts +++ b/src/components/ConditionalFilter/FilterElement/Condition.ts @@ -2,7 +2,7 @@ import { InitialStateResponse } from "../API/InitialStateResponse"; import { LeftOperand } from "../LeftOperandsProvider"; import { UrlToken } from "./../ValueProvider/UrlToken"; -import { ConditionOptions } from "./ConditionOptions"; +import { ConditionOptions, StaticElementName } from "./ConditionOptions"; import { ConditionSelected } from "./ConditionSelected"; import { ItemOption } from "./ConditionValue"; @@ -33,6 +33,16 @@ export class Condition { ); } + public static emptyFromSlug(slug: StaticElementName) { + const options = ConditionOptions.fromName(slug); + + return new Condition( + options, + ConditionSelected.fromConditionItem(options.first()), + false, + ); + } + public static emptyFromLeftOperand(operand: LeftOperand) { const options = ConditionOptions.fromName(operand.type); diff --git a/src/components/ConditionalFilter/FilterElement/Constraint.ts b/src/components/ConditionalFilter/FilterElement/Constraint.ts new file mode 100644 index 000000000..3f9b3c71a --- /dev/null +++ b/src/components/ConditionalFilter/FilterElement/Constraint.ts @@ -0,0 +1,43 @@ +import { CONSTRAINTS } from "../constants" +import { StaticElementName } from "./ConditionOptions" +import { FilterContainer, FilterElement } from "./FilterElement" + +type DisabledScope = "left" | "right" | "condition" + +export class Constraint { + constructor( + public dependsOn: string[], + public disabled?: [DisabledScope], + public removable?: boolean, + ) {} + + public static getDependency (slug: string): StaticElementName | null { + const fieldConstraint = Object.entries(CONSTRAINTS) + .find(([_, v]) => v.dependsOn.includes(slug)) + + if (!fieldConstraint) return null + + return fieldConstraint[0] as StaticElementName + } + + public static fromSlug(slug: string) { + const constraintKey = Object.keys(CONSTRAINTS).find(key => key === slug) + + if (!constraintKey) return null + const fieldConstraint = CONSTRAINTS[constraintKey as keyof typeof CONSTRAINTS] + + return new Constraint( + fieldConstraint.dependsOn, + fieldConstraint.disabled as [DisabledScope], + fieldConstraint.removable + ) + } + + public existIn (container: FilterContainer) { + return container.some((s) => { + if (!FilterElement.isCompatible(s)) return false + + return this.dependsOn.includes(s.value.value) + }) + } +} \ No newline at end of file diff --git a/src/components/ConditionalFilter/FilterElement/FilterElement.ts b/src/components/ConditionalFilter/FilterElement/FilterElement.ts index b340a0173..baa6d70aa 100644 --- a/src/components/ConditionalFilter/FilterElement/FilterElement.ts +++ b/src/components/ConditionalFilter/FilterElement/FilterElement.ts @@ -4,9 +4,10 @@ import { RowType, STATIC_OPTIONS } from "../constants"; import { LeftOperand } from "../LeftOperandsProvider"; import { TokenType, UrlEntry, UrlToken } from "./../ValueProvider/UrlToken"; import { Condition } from "./Condition"; -import { ConditionItem, ConditionOptions } from "./ConditionOptions"; +import { ConditionItem, ConditionOptions, StaticElementName } from "./ConditionOptions"; import { ConditionSelected } from "./ConditionSelected"; import { ConditionValue, ItemOption } from "./ConditionValue"; +import { Constraint } from "./Constraint"; class ExpressionValue { constructor( @@ -15,6 +16,18 @@ class ExpressionValue { public type: string, ) {} + public static fromSlug (slug: string) { + const option = STATIC_OPTIONS.find(o => o.slug === slug) + + if (!option) return ExpressionValue.emptyStatic() + + return new ExpressionValue( + option.slug, + option.label, + option.type, + ); + } + public static fromLeftOperand(leftOperand: LeftOperand) { return new ExpressionValue( leftOperand.slug, @@ -56,7 +69,14 @@ export class FilterElement { public value: ExpressionValue, public condition: Condition, public loading: boolean, - ) {} + public constraint?: Constraint, + ) { + const newConstraint = Constraint.fromSlug(this.value.value) + + if (newConstraint) { + this.constraint = newConstraint + } + } public enableLoading() { this.loading = true; @@ -125,6 +145,15 @@ export class FilterElement { return null; } + public setConstraint (constraint: Constraint) { + this.constraint = constraint + } + + public clearConstraint () { + this.constraint = undefined + } + + public asUrlEntry(): UrlEntry { if (this.isAttribute()) { return UrlEntry.forAttribute(this.condition.selected, this.value.value); @@ -133,6 +162,10 @@ export class FilterElement { return UrlEntry.forStatic(this.condition.selected, this.value.value); } + public static isCompatible(element: unknown): element is FilterElement { + return typeof element !== "string" && !Array.isArray(element) + } + public static fromValueEntry(valueEntry: any) { return new FilterElement(valueEntry.value, valueEntry.condition, false); } @@ -145,6 +178,14 @@ export class FilterElement { ); } + public static createStaticBySlug (slug: StaticElementName) { + return new FilterElement( + ExpressionValue.fromSlug(slug), + Condition.emptyFromSlug(slug), + false, + ); + } + public static fromUrlToken(token: UrlToken, response: InitialStateResponse) { if (token.isStatic()) { return new FilterElement( diff --git a/src/components/ConditionalFilter/FiltersArea.tsx b/src/components/ConditionalFilter/FiltersArea.tsx index 31d9e0709..cfae55049 100644 --- a/src/components/ConditionalFilter/FiltersArea.tsx +++ b/src/components/ConditionalFilter/FiltersArea.tsx @@ -3,6 +3,7 @@ import React from "react"; import { useConditionalFilterContext } from "./context"; import { FilterContainer } from "./FilterElement"; +import { LeftOperand } from "./LeftOperandsProvider"; import { useFilterContainer } from "./useFilterContainer"; interface FiltersAreaProps { @@ -35,7 +36,7 @@ export const FiltersArea = ({ onConfirm }: FiltersAreaProps) => { } if (event.type === "leftOperator.onChange") { - updateLeftOperator(event.path, event.value); + updateLeftOperator(event.path, event.value as LeftOperand); } if (event.type === "condition.onChange") { diff --git a/src/components/ConditionalFilter/constants.ts b/src/components/ConditionalFilter/constants.ts index 5e0464eee..c8de24a53 100644 --- a/src/components/ConditionalFilter/constants.ts +++ b/src/components/ConditionalFilter/constants.ts @@ -26,6 +26,15 @@ export const STATIC_CONDITIONS = { giftCard: [{ type: "select", label: "is", value: "input-1" }], }; + +export const CONSTRAINTS = { + channel: { + dependsOn: ["price", "isVisibleInListing"], + removable: true, + disabled: ["left", "condition"] + }, +} + export const STATIC_OPTIONS: LeftOperand[] = [ { value: "price", label: "Price", type: "price", slug: "price" }, { value: "category", label: "Category", type: "category", slug: "category" }, diff --git a/src/components/ConditionalFilter/useContainerState.ts b/src/components/ConditionalFilter/useContainerState.ts index 920dbc412..60bfe74eb 100644 --- a/src/components/ConditionalFilter/useContainerState.ts +++ b/src/components/ConditionalFilter/useContainerState.ts @@ -6,6 +6,49 @@ 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 (!el.constraint?.existIn(container)) { + el.clearConstraint() + } + + return el + }) +} + +const calculateIndexesToRemove = (container: FilterContainer, position: number) => { + const next = position + 1 + const previous = position - 1 + const indexTuple = [position] + + if (typeof container[next] === "string") { + indexTuple.push(next) + + return indexTuple + } + + if (typeof container[previous] === "string") { + indexTuple.push(previous) + } + + return indexTuple +} + + +const removeElement = (container: FilterContainer, position: number) => { + const indexTuple = calculateIndexesToRemove(container, position) + + const newContainer = container + .filter((_, elIndex) => !indexTuple.includes(elIndex)) + + return removeConstraint(newContainer) +} + export const useContainerState = (valueProvider: FilterValueProvider) => { const [value, setValue] = useState([]); @@ -15,17 +58,17 @@ export const useContainerState = (valueProvider: FilterValueProvider) => { } }, [valueProvider.loading]); - const isFilterElement = ( + const isFilterElementAtIndex = ( elIndex: number, index: number, el: Element, ): el is FilterElement => { - return elIndex === index && typeof el !== "string" && !Array.isArray(el); + return elIndex === index && isFilterElement(el); }; const updateFilterElement = (index: number, cb: StateCallback) => (el: Element, elIndex: number) => { - if (isFilterElement(elIndex, index, el)) { + if (isFilterElementAtIndex(elIndex, index, el)) { cb(el); } @@ -37,32 +80,48 @@ export const useContainerState = (valueProvider: FilterValueProvider) => { setValue(v => v.map(updateFilterElement(index, cb))); }; + const updateBySlug = (slug: string, cb: StateCallback) => { + setValue(v => v.map((el) => { + if (isFilterElement(el) && el.value.value === slug) { + cb(el) + } + + return el + })) + }; + const removeAt = (position: string) => { const index = parseInt(position, 10); - if (value.length > 0) { - setValue(v => - v.filter((_, elIndex) => ![index - 1, index].includes(elIndex)), - ); - return; - } - - setValue(v => v.filter((_, elIndex) => ![index].includes(elIndex))); + setValue(v => removeElement(v, index)); }; - const createEmpty = () => { + const create = (element: FilterElement) => { const newValue: FilterContainer = []; if (value.length > 0) { - newValue.push("OR"); + newValue.push("AND"); } - newValue.push(FilterElement.createEmpty()); + newValue.push(element); setValue(v => v.concat(newValue)); }; + const exist = (slug: string) => { + return value.some((entry) => + isFilterElement(entry) && entry.value.value === slug + ) + } + + const createEmpty = () => { + create(FilterElement.createEmpty()) + }; + return { + create, + exist, + updateBySlug, createEmpty, updateAt, removeAt, diff --git a/src/components/ConditionalFilter/useFilterContainer.ts b/src/components/ConditionalFilter/useFilterContainer.ts index f7ca17eca..527fbf1c1 100644 --- a/src/components/ConditionalFilter/useFilterContainer.ts +++ b/src/components/ConditionalFilter/useFilterContainer.ts @@ -2,23 +2,40 @@ import useDebounce from "@dashboard/hooks/useDebounce"; import { FilterAPIProvider } from "./API/FilterAPIProvider"; import { useConditionalFilterContext } from "./context"; +import { FilterElement } from "./FilterElement"; import { ConditionValue, ItemOption } from "./FilterElement/ConditionValue"; -import { LeftOperandsProvider } from "./LeftOperandsProvider"; +import { Constraint } from "./FilterElement/Constraint"; +import { LeftOperand, LeftOperandsProvider } from "./LeftOperandsProvider"; export const useFilterContainer = ( apiProvider: FilterAPIProvider, leftOperandsProvider: LeftOperandsProvider, ) => { const { - containerState: { value, updateAt, removeAt, createEmpty }, + containerState: { value, updateAt, removeAt, createEmpty, create, exist, updateBySlug }, } = useConditionalFilterContext(); const addEmpty = () => { createEmpty(); }; - const updateLeftOperator = (position: string, leftOperator: any) => { + const updateLeftOperator = (position: string, leftOperator: LeftOperand) => { updateAt(position, el => el.updateLeftOperator(leftOperator)); + + const dependency = Constraint.getDependency(leftOperator.value) + + if (!dependency) return + + if (!exist(dependency)) { + create(FilterElement.createStaticBySlug(dependency)) + return + } + + updateBySlug(dependency, (el) => { + const newConstraint = Constraint.fromSlug(dependency) + + if (newConstraint) el.setConstraint(newConstraint) + }) }; const updateLeftLoadingState = (position: string, loading: boolean) => {