Dependencies between rows in filtering (#3956)

* Fields constraint

* Keep constraing on the channel level

* Keep constraing on the channel level
This commit is contained in:
Patryk Andrzejewski 2023-07-20 16:53:00 +02:00 committed by GitHub
parent 09d254d50a
commit 23bb5976c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 206 additions and 21 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-dashboard": minor
---
Constrains implementation for filter rows

View file

@ -2,7 +2,7 @@
import { InitialStateResponse } from "../API/InitialStateResponse"; import { InitialStateResponse } from "../API/InitialStateResponse";
import { LeftOperand } from "../LeftOperandsProvider"; import { LeftOperand } from "../LeftOperandsProvider";
import { UrlToken } from "./../ValueProvider/UrlToken"; import { UrlToken } from "./../ValueProvider/UrlToken";
import { ConditionOptions } from "./ConditionOptions"; import { ConditionOptions, StaticElementName } from "./ConditionOptions";
import { ConditionSelected } from "./ConditionSelected"; import { ConditionSelected } from "./ConditionSelected";
import { ItemOption } from "./ConditionValue"; 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) { public static emptyFromLeftOperand(operand: LeftOperand) {
const options = ConditionOptions.fromName(operand.type); const options = ConditionOptions.fromName(operand.type);

View file

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

View file

@ -4,9 +4,10 @@ import { RowType, STATIC_OPTIONS } from "../constants";
import { LeftOperand } from "../LeftOperandsProvider"; import { LeftOperand } from "../LeftOperandsProvider";
import { TokenType, UrlEntry, UrlToken } from "./../ValueProvider/UrlToken"; import { TokenType, UrlEntry, UrlToken } from "./../ValueProvider/UrlToken";
import { Condition } from "./Condition"; import { Condition } from "./Condition";
import { ConditionItem, ConditionOptions } from "./ConditionOptions"; import { ConditionItem, ConditionOptions, StaticElementName } from "./ConditionOptions";
import { ConditionSelected } from "./ConditionSelected"; import { ConditionSelected } from "./ConditionSelected";
import { ConditionValue, ItemOption } from "./ConditionValue"; import { ConditionValue, ItemOption } from "./ConditionValue";
import { Constraint } from "./Constraint";
class ExpressionValue { class ExpressionValue {
constructor( constructor(
@ -15,6 +16,18 @@ class ExpressionValue {
public type: string, 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) { public static fromLeftOperand(leftOperand: LeftOperand) {
return new ExpressionValue( return new ExpressionValue(
leftOperand.slug, leftOperand.slug,
@ -56,7 +69,14 @@ export class FilterElement {
public value: ExpressionValue, public value: ExpressionValue,
public condition: Condition, public condition: Condition,
public loading: boolean, public loading: boolean,
) {} public constraint?: Constraint,
) {
const newConstraint = Constraint.fromSlug(this.value.value)
if (newConstraint) {
this.constraint = newConstraint
}
}
public enableLoading() { public enableLoading() {
this.loading = true; this.loading = true;
@ -125,6 +145,15 @@ export class FilterElement {
return null; return null;
} }
public setConstraint (constraint: Constraint) {
this.constraint = constraint
}
public clearConstraint () {
this.constraint = undefined
}
public asUrlEntry(): UrlEntry { public asUrlEntry(): UrlEntry {
if (this.isAttribute()) { if (this.isAttribute()) {
return UrlEntry.forAttribute(this.condition.selected, this.value.value); 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); 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) { public static fromValueEntry(valueEntry: any) {
return new FilterElement(valueEntry.value, valueEntry.condition, false); 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) { public static fromUrlToken(token: UrlToken, response: InitialStateResponse) {
if (token.isStatic()) { if (token.isStatic()) {
return new FilterElement( return new FilterElement(

View file

@ -3,6 +3,7 @@ import React from "react";
import { useConditionalFilterContext } from "./context"; import { useConditionalFilterContext } from "./context";
import { FilterContainer } from "./FilterElement"; import { FilterContainer } from "./FilterElement";
import { LeftOperand } from "./LeftOperandsProvider";
import { useFilterContainer } from "./useFilterContainer"; import { useFilterContainer } from "./useFilterContainer";
interface FiltersAreaProps { interface FiltersAreaProps {
@ -35,7 +36,7 @@ export const FiltersArea = ({ onConfirm }: FiltersAreaProps) => {
} }
if (event.type === "leftOperator.onChange") { if (event.type === "leftOperator.onChange") {
updateLeftOperator(event.path, event.value); updateLeftOperator(event.path, event.value as LeftOperand);
} }
if (event.type === "condition.onChange") { if (event.type === "condition.onChange") {

View file

@ -26,6 +26,15 @@ export const STATIC_CONDITIONS = {
giftCard: [{ type: "select", label: "is", value: "input-1" }], 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[] = [ export const STATIC_OPTIONS: LeftOperand[] = [
{ value: "price", label: "Price", type: "price", slug: "price" }, { value: "price", label: "Price", type: "price", slug: "price" },
{ value: "category", label: "Category", type: "category", slug: "category" }, { value: "category", label: "Category", type: "category", slug: "category" },

View file

@ -6,6 +6,49 @@ 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) => {
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) => { export const useContainerState = (valueProvider: FilterValueProvider) => {
const [value, setValue] = useState<FilterContainer>([]); const [value, setValue] = useState<FilterContainer>([]);
@ -15,17 +58,17 @@ export const useContainerState = (valueProvider: FilterValueProvider) => {
} }
}, [valueProvider.loading]); }, [valueProvider.loading]);
const isFilterElement = ( const isFilterElementAtIndex = (
elIndex: number, elIndex: number,
index: number, index: number,
el: Element, el: Element,
): el is FilterElement => { ): el is FilterElement => {
return elIndex === index && typeof el !== "string" && !Array.isArray(el); return elIndex === index && isFilterElement(el);
}; };
const updateFilterElement = const updateFilterElement =
(index: number, cb: StateCallback) => (el: Element, elIndex: number) => { (index: number, cb: StateCallback) => (el: Element, elIndex: number) => {
if (isFilterElement(elIndex, index, el)) { if (isFilterElementAtIndex(elIndex, index, el)) {
cb(el); cb(el);
} }
@ -37,32 +80,48 @@ export const useContainerState = (valueProvider: FilterValueProvider) => {
setValue(v => v.map(updateFilterElement(index, cb))); 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 removeAt = (position: string) => {
const index = parseInt(position, 10); const index = parseInt(position, 10);
if (value.length > 0) { setValue(v => removeElement(v, index));
setValue(v =>
v.filter((_, elIndex) => ![index - 1, index].includes(elIndex)),
);
return;
}
setValue(v => v.filter((_, elIndex) => ![index].includes(elIndex)));
}; };
const createEmpty = () => { const create = (element: FilterElement) => {
const newValue: FilterContainer = []; const newValue: FilterContainer = [];
if (value.length > 0) { if (value.length > 0) {
newValue.push("OR"); newValue.push("AND");
} }
newValue.push(FilterElement.createEmpty()); newValue.push(element);
setValue(v => v.concat(newValue)); 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 { return {
create,
exist,
updateBySlug,
createEmpty, createEmpty,
updateAt, updateAt,
removeAt, removeAt,

View file

@ -2,23 +2,40 @@ import useDebounce from "@dashboard/hooks/useDebounce";
import { FilterAPIProvider } from "./API/FilterAPIProvider"; import { FilterAPIProvider } from "./API/FilterAPIProvider";
import { useConditionalFilterContext } from "./context"; import { useConditionalFilterContext } from "./context";
import { FilterElement } from "./FilterElement";
import { ConditionValue, ItemOption } from "./FilterElement/ConditionValue"; import { ConditionValue, ItemOption } from "./FilterElement/ConditionValue";
import { LeftOperandsProvider } from "./LeftOperandsProvider"; import { Constraint } from "./FilterElement/Constraint";
import { LeftOperand, LeftOperandsProvider } from "./LeftOperandsProvider";
export const useFilterContainer = ( export const useFilterContainer = (
apiProvider: FilterAPIProvider, apiProvider: FilterAPIProvider,
leftOperandsProvider: LeftOperandsProvider, leftOperandsProvider: LeftOperandsProvider,
) => { ) => {
const { const {
containerState: { value, updateAt, removeAt, createEmpty }, containerState: { value, updateAt, removeAt, createEmpty, create, exist, updateBySlug },
} = useConditionalFilterContext(); } = useConditionalFilterContext();
const addEmpty = () => { const addEmpty = () => {
createEmpty(); createEmpty();
}; };
const updateLeftOperator = (position: string, leftOperator: any) => { const updateLeftOperator = (position: string, leftOperator: LeftOperand) => {
updateAt(position, el => el.updateLeftOperator(leftOperator)); 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) => { const updateLeftLoadingState = (position: string, loading: boolean) => {