From f8dd73b867d31df55e5fb4757f1f69582c3509e5 Mon Sep 17 00:00:00 2001 From: Marco Montalbano Date: Tue, 10 Feb 2026 14:53:40 +0100 Subject: [PATCH 1/4] feat(RuleEngine): update json-schema and manage the new quantity attribute --- .../RuleEngine/Action/ActionListItem.tsx | 3 + .../src/ui/forms/RuleEngine/Options/index.tsx | 35 ++++ .../RuleEngine/json_schema/order_rules.json | 171 +++++++++++++++++- .../json_schema/order_rules.schema.ts | 116 ++++++++++++ .../RuleEngine/json_schema/price_rules.json | 171 +++++++++++++++++- .../json_schema/price_rules.schema.ts | 116 ++++++++++++ .../ui/forms/RuleEngine/optionsConfig.test.ts | 48 +++++ .../src/ui/forms/RuleEngine/optionsConfig.ts | 4 + 8 files changed, 662 insertions(+), 2 deletions(-) diff --git a/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionListItem.tsx b/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionListItem.tsx index 60fc85b8..e350c251 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionListItem.tsx +++ b/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionListItem.tsx @@ -112,6 +112,9 @@ export function ActionListItem({ type: "balanced", }) break + case "quantity": + setPath(`${pathPrefix}.quantity`, 1) + break } }} label={option.label} diff --git a/packages/app-elements/src/ui/forms/RuleEngine/Options/index.tsx b/packages/app-elements/src/ui/forms/RuleEngine/Options/index.tsx index f17b59dc..5553535b 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/Options/index.tsx +++ b/packages/app-elements/src/ui/forms/RuleEngine/Options/index.tsx @@ -39,6 +39,7 @@ export function Options({ + ) } @@ -129,6 +130,40 @@ function RoundOption({ ) } +function QuantityOption({ + item, + pathPrefix, +}: { + item: SchemaActionItem | SchemaConditionItem + pathPrefix: string +}) { + const optionName = "quantity" as const + + const { setPath } = useRuleEngine() + const optionRow = useOptionRow({ item, optionName, pathPrefix }) + + if (!(optionName in item) || optionRow == null) { + return null + } + + const defaultValue = item[optionName] + + return ( + + { + const value = parseInt(e.currentTarget.value, 10) + setPath(`${pathPrefix}.${optionName}`, value) + }} + /> + + ) +} + function LimitOption({ item, pathPrefix, diff --git a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.json b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.json index c0a58cbc..7ce24710 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.json +++ b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "1.1.7", + "$id": "1.1.8", "title": "Rules for order context", "description": "Rules payload within order context for the rules engine of Commerce Layer.", "properties": { @@ -84,6 +84,58 @@ "required": ["type", "selector", "value"], "additionalProperties": false }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["percentage"], + "description": "The type of action you want to apply.", + "examples": ["percentage"] + }, + "selector": { "$ref": "#/$defs/selector" }, + "identifier": { "$ref": "#/$defs/identifier" }, + "groups": { "$ref": "#/$defs/groups" }, + "aggregation": { "$ref": "#/$defs/aggregation" }, + "value": { + "type": "number", + "description": "Percentage to be discounted,", + "examples": [0.2, 0.3] + }, + "round": { "$ref": "#/$defs/round" }, + "apply_on": { "$ref": "#/$defs/apply_on" }, + "bundle": { "$ref": "#/$defs/bundle" }, + "quantity": { "$ref": "#/$defs/quantity" } + }, + "required": ["type", "selector", "value", "groups"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["percentage"], + "description": "The type of action you want to apply.", + "examples": ["percentage"] + }, + "selector": { "$ref": "#/$defs/selector" }, + "identifier": { "$ref": "#/$defs/identifier" }, + "groups": { "$ref": "#/$defs/groups" }, + "aggregation": { "$ref": "#/$defs/aggregation" }, + "value": { + "type": "number", + "description": "Percentage to be discounted,", + "examples": [0.2, 0.3] + }, + "round": { "$ref": "#/$defs/round" }, + "apply_on": { "$ref": "#/$defs/apply_on" }, + "limit": { "$ref": "#/$defs/limit" }, + "quantity": { "$ref": "#/$defs/quantity" } + }, + "required": ["type", "selector", "value"], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -144,6 +196,68 @@ "required": ["type", "selector", "value"], "additionalProperties": false }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["fixed_amount"], + "description": "The type of action you want to apply.", + "examples": ["fixed_amount"] + }, + "selector": { "$ref": "#/$defs/selector" }, + "identifier": { "$ref": "#/$defs/identifier" }, + "groups": { "$ref": "#/$defs/groups" }, + "aggregation": { "$ref": "#/$defs/aggregation" }, + "value": { + "type": "integer", + "description": "The discount fixed amount to be applied.", + "examples": [10] + }, + "bundle": { "$ref": "#/$defs/bundle" }, + "apply_on": { "$ref": "#/$defs/apply_on" }, + "quantity": { "$ref": "#/$defs/quantity" }, + "discount_mode": { + "type": "string", + "enum": ["distributed", "default"], + "description": "The type of distribution of the discount over the items.", + "examples": ["distributed"] + } + }, + "required": ["type", "selector", "value", "groups"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["fixed_amount"], + "description": "The type of action you want to apply.", + "examples": ["fixed_amount"] + }, + "selector": { "$ref": "#/$defs/selector" }, + "identifier": { "$ref": "#/$defs/identifier" }, + "groups": { "$ref": "#/$defs/groups" }, + "aggregation": { "$ref": "#/$defs/aggregation" }, + "value": { + "type": "integer", + "description": "The discount fixed amount to be applied.", + "examples": [10] + }, + "apply_on": { "$ref": "#/$defs/apply_on" }, + "limit": { "$ref": "#/$defs/limit" }, + "quantity": { "$ref": "#/$defs/quantity" }, + "discount_mode": { + "type": "string", + "enum": ["distributed", "default"], + "description": "The type of distribution of the discount over the items.", + "examples": ["distributed"] + } + }, + "required": ["type", "selector", "value"], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -192,6 +306,56 @@ "required": ["type", "selector", "value"], "additionalProperties": false }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["fixed_price"], + "description": "The type of action you want to apply.", + "examples": ["fixed_price"] + }, + "selector": { "$ref": "#/$defs/selector" }, + "identifier": { "$ref": "#/$defs/identifier" }, + "groups": { "$ref": "#/$defs/groups" }, + "aggregation": { "$ref": "#/$defs/aggregation" }, + "value": { + "type": "integer", + "description": "The price fixed amount to be applied.", + "examples": [20] + }, + "bundle": { "$ref": "#/$defs/bundle" }, + "apply_on": { "$ref": "#/$defs/apply_on" }, + "quantity": { "$ref": "#/$defs/quantity" } + }, + "required": ["type", "selector", "value", "groups"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["fixed_price"], + "description": "The type of action you want to apply.", + "examples": ["fixed_price"] + }, + "selector": { "$ref": "#/$defs/selector" }, + "identifier": { "$ref": "#/$defs/identifier" }, + "groups": { "$ref": "#/$defs/groups" }, + "aggregation": { "$ref": "#/$defs/aggregation" }, + "value": { + "type": "integer", + "description": "The price fixed amount to be applied.", + "examples": [20] + }, + "apply_on": { "$ref": "#/$defs/apply_on" }, + "limit": { "$ref": "#/$defs/limit" }, + "quantity": { "$ref": "#/$defs/quantity" } + }, + "required": ["type", "selector", "value"], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -779,6 +943,11 @@ "description": "If true, rounds the discount, only available on percentage actions.", "examples": [true, false] }, + "quantity": { + "type": "integer", + "description": "Optional quantity to override the resource quantity. If specified, the action will apply to the minimum of specified quantity and resource quantity.", + "examples": [1, 2] + }, "aggregation": { "type": "object", "properties": { diff --git a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.schema.ts b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.schema.ts index 4a360b80..91dd8418 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.schema.ts +++ b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.schema.ts @@ -155,6 +155,10 @@ export type Bundle = */ value: number } +/** + * Optional quantity to override the resource quantity. If specified, the action will apply to the minimum of specified quantity and resource quantity. + */ +export type Quantity = number /** * Rules payload within order context for the rules engine of Commerce Layer. @@ -216,6 +220,82 @@ export interface RulesForOrderContext { apply_on?: ApplyOn limit?: Limit } + | { + /** + * The type of action you want to apply. + */ + type: "percentage" + selector: Selector + identifier?: Identifier + groups: Groups + aggregation?: Aggregation + /** + * Percentage to be discounted, + */ + value: number + round?: Round + apply_on?: ApplyOn + bundle?: Bundle + quantity?: Quantity + } + | { + /** + * The type of action you want to apply. + */ + type: "percentage" + selector: Selector + identifier?: Identifier + groups?: Groups + aggregation?: Aggregation + /** + * Percentage to be discounted, + */ + value: number + round?: Round + apply_on?: ApplyOn + limit?: Limit + quantity?: Quantity + } + | { + /** + * The type of action you want to apply. + */ + type: "fixed_amount" + selector: Selector + identifier?: Identifier + groups: Groups + aggregation?: Aggregation + /** + * The discount fixed amount to be applied. + */ + value: number + bundle?: Bundle + apply_on?: ApplyOn + /** + * The type of distribution of the discount over the items. + */ + discount_mode?: "distributed" | "default" + } + | { + /** + * The type of action you want to apply. + */ + type: "fixed_amount" + selector: Selector + identifier?: Identifier + groups?: Groups + aggregation?: Aggregation + /** + * The discount fixed amount to be applied. + */ + value: number + apply_on?: ApplyOn + limit?: Limit + /** + * The type of distribution of the discount over the items. + */ + discount_mode?: "distributed" | "default" + } | { /** * The type of action you want to apply. @@ -231,6 +311,7 @@ export interface RulesForOrderContext { value: number bundle?: Bundle apply_on?: ApplyOn + quantity?: Quantity /** * The type of distribution of the discount over the items. */ @@ -251,6 +332,7 @@ export interface RulesForOrderContext { value: number apply_on?: ApplyOn limit?: Limit + quantity?: Quantity /** * The type of distribution of the discount over the items. */ @@ -288,6 +370,40 @@ export interface RulesForOrderContext { apply_on?: ApplyOn limit?: Limit } + | { + /** + * The type of action you want to apply. + */ + type: "fixed_price" + selector: Selector + identifier?: Identifier + groups: Groups + aggregation?: Aggregation + /** + * The price fixed amount to be applied. + */ + value: number + bundle?: Bundle + apply_on?: ApplyOn + quantity?: Quantity + } + | { + /** + * The type of action you want to apply. + */ + type: "fixed_price" + selector: Selector + identifier?: Identifier + groups?: Groups + aggregation?: Aggregation + /** + * The price fixed amount to be applied. + */ + value: number + apply_on?: ApplyOn + limit?: Limit + quantity?: Quantity + } | { /** * The type of action you want to apply. diff --git a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.json b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.json index ff07dcfc..685d1a54 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.json +++ b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "1.1.7", + "$id": "1.1.8", "title": "Rules for price context", "description": "Rules payload within price context for the rules engine of Commerce Layer.", "properties": { @@ -84,6 +84,58 @@ "required": ["type", "selector", "value"], "additionalProperties": false }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["percentage"], + "description": "The type of action you want to apply.", + "examples": ["percentage"] + }, + "selector": { "$ref": "#/$defs/selector" }, + "identifier": { "$ref": "#/$defs/identifier" }, + "groups": { "$ref": "#/$defs/groups" }, + "aggregation": { "$ref": "#/$defs/aggregation" }, + "value": { + "type": "number", + "description": "Percentage to be discounted,", + "examples": [0.2, 0.3] + }, + "round": { "$ref": "#/$defs/round" }, + "apply_on": { "$ref": "#/$defs/apply_on" }, + "bundle": { "$ref": "#/$defs/bundle" }, + "quantity": { "$ref": "#/$defs/quantity" } + }, + "required": ["type", "selector", "value", "groups"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["percentage"], + "description": "The type of action you want to apply.", + "examples": ["percentage"] + }, + "selector": { "$ref": "#/$defs/selector" }, + "identifier": { "$ref": "#/$defs/identifier" }, + "groups": { "$ref": "#/$defs/groups" }, + "aggregation": { "$ref": "#/$defs/aggregation" }, + "value": { + "type": "number", + "description": "Percentage to be discounted,", + "examples": [0.2, 0.3] + }, + "round": { "$ref": "#/$defs/round" }, + "apply_on": { "$ref": "#/$defs/apply_on" }, + "limit": { "$ref": "#/$defs/limit" }, + "quantity": { "$ref": "#/$defs/quantity" } + }, + "required": ["type", "selector", "value"], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -144,6 +196,68 @@ "required": ["type", "selector", "value"], "additionalProperties": false }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["fixed_amount"], + "description": "The type of action you want to apply.", + "examples": ["fixed_amount"] + }, + "selector": { "$ref": "#/$defs/selector" }, + "identifier": { "$ref": "#/$defs/identifier" }, + "groups": { "$ref": "#/$defs/groups" }, + "aggregation": { "$ref": "#/$defs/aggregation" }, + "value": { + "type": "integer", + "description": "The discount fixed amount to be applied.", + "examples": [10] + }, + "bundle": { "$ref": "#/$defs/bundle" }, + "apply_on": { "$ref": "#/$defs/apply_on" }, + "quantity": { "$ref": "#/$defs/quantity" }, + "discount_mode": { + "type": "string", + "enum": ["distributed", "default"], + "description": "The type of distribution of the discount over the items.", + "examples": ["distributed"] + } + }, + "required": ["type", "selector", "value", "groups"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["fixed_amount"], + "description": "The type of action you want to apply.", + "examples": ["fixed_amount"] + }, + "selector": { "$ref": "#/$defs/selector" }, + "identifier": { "$ref": "#/$defs/identifier" }, + "groups": { "$ref": "#/$defs/groups" }, + "aggregation": { "$ref": "#/$defs/aggregation" }, + "value": { + "type": "integer", + "description": "The discount fixed amount to be applied.", + "examples": [10] + }, + "apply_on": { "$ref": "#/$defs/apply_on" }, + "limit": { "$ref": "#/$defs/limit" }, + "quantity": { "$ref": "#/$defs/quantity" }, + "discount_mode": { + "type": "string", + "enum": ["distributed", "default"], + "description": "The type of distribution of the discount over the items.", + "examples": ["distributed"] + } + }, + "required": ["type", "selector", "value"], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -191,6 +305,56 @@ }, "required": ["type", "selector", "value"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["fixed_price"], + "description": "The type of action you want to apply.", + "examples": ["fixed_price"] + }, + "selector": { "$ref": "#/$defs/selector" }, + "identifier": { "$ref": "#/$defs/identifier" }, + "groups": { "$ref": "#/$defs/groups" }, + "aggregation": { "$ref": "#/$defs/aggregation" }, + "value": { + "type": "integer", + "description": "The price fixed amount to be applied.", + "examples": [20] + }, + "bundle": { "$ref": "#/$defs/bundle" }, + "apply_on": { "$ref": "#/$defs/apply_on" }, + "quantity": { "$ref": "#/$defs/quantity" } + }, + "required": ["type", "selector", "value", "groups"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["fixed_price"], + "description": "The type of action you want to apply.", + "examples": ["fixed_price"] + }, + "selector": { "$ref": "#/$defs/selector" }, + "identifier": { "$ref": "#/$defs/identifier" }, + "groups": { "$ref": "#/$defs/groups" }, + "aggregation": { "$ref": "#/$defs/aggregation" }, + "value": { + "type": "integer", + "description": "The price fixed amount to be applied.", + "examples": [20] + }, + "apply_on": { "$ref": "#/$defs/apply_on" }, + "limit": { "$ref": "#/$defs/limit" }, + "quantity": { "$ref": "#/$defs/quantity" } + }, + "required": ["type", "selector", "value"], + "additionalProperties": false } ] } @@ -701,6 +865,11 @@ "description": "If true, rounds the discount, only available on percentage actions.", "examples": [true, false] }, + "quantity": { + "type": "integer", + "description": "Optional quantity to override the resource quantity. If specified, the action will apply to the minimum of specified quantity and resource quantity.", + "examples": [1, 2] + }, "aggregation": { "type": "object", "properties": { diff --git a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.schema.ts b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.schema.ts index 1abadb3b..381bc779 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.schema.ts +++ b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.schema.ts @@ -155,6 +155,10 @@ export type Bundle = */ value: number } +/** + * Optional quantity to override the resource quantity. If specified, the action will apply to the minimum of specified quantity and resource quantity. + */ +export type Quantity = number /** * Rules payload within price context for the rules engine of Commerce Layer. @@ -216,6 +220,82 @@ export interface RulesForPriceContext { apply_on?: ApplyOn limit?: Limit } + | { + /** + * The type of action you want to apply. + */ + type: "percentage" + selector: Selector + identifier?: Identifier + groups: Groups + aggregation?: Aggregation + /** + * Percentage to be discounted, + */ + value: number + round?: Round + apply_on?: ApplyOn + bundle?: Bundle + quantity?: Quantity + } + | { + /** + * The type of action you want to apply. + */ + type: "percentage" + selector: Selector + identifier?: Identifier + groups?: Groups + aggregation?: Aggregation + /** + * Percentage to be discounted, + */ + value: number + round?: Round + apply_on?: ApplyOn + limit?: Limit + quantity?: Quantity + } + | { + /** + * The type of action you want to apply. + */ + type: "fixed_amount" + selector: Selector + identifier?: Identifier + groups: Groups + aggregation?: Aggregation + /** + * The discount fixed amount to be applied. + */ + value: number + bundle?: Bundle + apply_on?: ApplyOn + /** + * The type of distribution of the discount over the items. + */ + discount_mode?: "distributed" | "default" + } + | { + /** + * The type of action you want to apply. + */ + type: "fixed_amount" + selector: Selector + identifier?: Identifier + groups?: Groups + aggregation?: Aggregation + /** + * The discount fixed amount to be applied. + */ + value: number + apply_on?: ApplyOn + limit?: Limit + /** + * The type of distribution of the discount over the items. + */ + discount_mode?: "distributed" | "default" + } | { /** * The type of action you want to apply. @@ -231,6 +311,7 @@ export interface RulesForPriceContext { value: number bundle?: Bundle apply_on?: ApplyOn + quantity?: Quantity /** * The type of distribution of the discount over the items. */ @@ -251,6 +332,7 @@ export interface RulesForPriceContext { value: number apply_on?: ApplyOn limit?: Limit + quantity?: Quantity /** * The type of distribution of the discount over the items. */ @@ -288,6 +370,40 @@ export interface RulesForPriceContext { apply_on?: ApplyOn limit?: Limit } + | { + /** + * The type of action you want to apply. + */ + type: "fixed_price" + selector: Selector + identifier?: Identifier + groups: Groups + aggregation?: Aggregation + /** + * The price fixed amount to be applied. + */ + value: number + bundle?: Bundle + apply_on?: ApplyOn + quantity?: Quantity + } + | { + /** + * The type of action you want to apply. + */ + type: "fixed_price" + selector: Selector + identifier?: Identifier + groups?: Groups + aggregation?: Aggregation + /** + * The price fixed amount to be applied. + */ + value: number + apply_on?: ApplyOn + limit?: Limit + quantity?: Quantity + } )[] }[] [k: string]: unknown diff --git a/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.test.ts b/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.test.ts index edeb79bf..b50f203d 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.test.ts +++ b/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.test.ts @@ -125,6 +125,14 @@ describe("parseOptionsFromSchema", () => { }, ], }, + { + "description": "Optional quantity to override the resource quantity. If specified, the action will apply to the minimum of specified quantity and resource quantity.", + "label": "Quantity", + "mutuallyExclusiveWith": [], + "name": "quantity", + "valueType": "integer", + "values": undefined, + }, ], "order.line_items": [ { @@ -215,6 +223,14 @@ describe("parseOptionsFromSchema", () => { }, ], }, + { + "description": "Optional quantity to override the resource quantity. If specified, the action will apply to the minimum of specified quantity and resource quantity.", + "label": "Quantity", + "mutuallyExclusiveWith": [], + "name": "quantity", + "valueType": "integer", + "values": undefined, + }, ], "order.line_items.adjustment": [], "order.line_items.bundle": [], @@ -297,6 +313,14 @@ describe("parseOptionsFromSchema", () => { }, ], }, + { + "description": "Optional quantity to override the resource quantity. If specified, the action will apply to the minimum of specified quantity and resource quantity.", + "label": "Quantity", + "mutuallyExclusiveWith": [], + "name": "quantity", + "valueType": "integer", + "values": undefined, + }, ], "order.line_items": [ { @@ -370,6 +394,14 @@ describe("parseOptionsFromSchema", () => { }, ], }, + { + "description": "Optional quantity to override the resource quantity. If specified, the action will apply to the minimum of specified quantity and resource quantity.", + "label": "Quantity", + "mutuallyExclusiveWith": [], + "name": "quantity", + "valueType": "integer", + "values": undefined, + }, ], "order.line_items.adjustment": [], "order.line_items.bundle": [], @@ -460,6 +492,14 @@ describe("parseOptionsFromSchema", () => { }, ], }, + { + "description": "Optional quantity to override the resource quantity. If specified, the action will apply to the minimum of specified quantity and resource quantity.", + "label": "Quantity", + "mutuallyExclusiveWith": [], + "name": "quantity", + "valueType": "integer", + "values": undefined, + }, ], "order.line_items": [ { @@ -541,6 +581,14 @@ describe("parseOptionsFromSchema", () => { }, ], }, + { + "description": "Optional quantity to override the resource quantity. If specified, the action will apply to the minimum of specified quantity and resource quantity.", + "label": "Quantity", + "mutuallyExclusiveWith": [], + "name": "quantity", + "valueType": "integer", + "values": undefined, + }, ], "order.line_items.adjustment": [], "order.line_items.bundle": [], diff --git a/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.ts b/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.ts index faa8c91f..219d33fd 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.ts +++ b/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.ts @@ -38,6 +38,7 @@ const configuration = { "order-rules": { order: { round: true, + quantity: true, discount_mode: true, apply_on: [ { label: "Subtotal amount", value: "subtotal_amount_cents" }, @@ -82,6 +83,7 @@ const configuration = { }, "order.line_items": { round: true, + quantity: true, discount_mode: true, apply_on: [ { label: "Unit amount", value: "unit_amount_cents" }, @@ -235,6 +237,7 @@ const MANAGED_ACTION_OPTIONS = [ "discount_mode", "aggregation", "bundle", + "quantity", ] as const const MANAGED_CONDITION_OPTIONS = ["group", "scope", "aggregations"] as const @@ -258,6 +261,7 @@ export const OPTION_LABELS: Record< scope: "Scope", aggregations: "Aggregations", group: "Group", + quantity: "Quantity", } as const /** From 94efe7be0d590d9c5023a13da380ea2bd0dec8ad Mon Sep 17 00:00:00 2001 From: Marco Montalbano Date: Thu, 12 Feb 2026 18:23:41 +0100 Subject: [PATCH 2/4] feat(RuleEngine): implement free gift action --- .../RuleEngine/Action/ActionListItem.tsx | 113 +++++++---- .../forms/RuleEngine/Action/ActionValue.tsx | 4 + .../src/ui/forms/RuleEngine/Action/index.tsx | 2 +- .../ValueComponents/InputResourceSelector.tsx | 93 +--------- .../ui/forms/RuleEngine/Condition/hooks.tsx | 5 +- .../RuleEngine/InputResourceSelector.tsx | 115 ++++++++++++ .../src/ui/forms/RuleEngine/Options/index.tsx | 175 +++++++++++++++++- .../RuleEngine/json_schema/order_rules.json | 130 ++++++++++++- .../json_schema/order_rules.schema.ts | 23 +++ .../json_schema/organization_config.json | 23 +++ .../RuleEngine/json_schema/price_rules.json | 91 ++++++++- .../ui/forms/RuleEngine/optionsConfig.test.ts | 116 ++++++++++++ .../src/ui/forms/RuleEngine/optionsConfig.ts | 26 ++- .../src/ui/forms/RuleEngine/utils.ts | 2 +- 14 files changed, 776 insertions(+), 142 deletions(-) create mode 100644 packages/app-elements/src/ui/forms/RuleEngine/InputResourceSelector.tsx diff --git a/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionListItem.tsx b/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionListItem.tsx index e350c251..d90a8b2e 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionListItem.tsx +++ b/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionListItem.tsx @@ -45,13 +45,12 @@ export function ActionListItem({ ] ?? []) : [] - const { available: availableOptions } = useAvailableOptions( - item, - actionOptionsConfig, - ) + const { available: availableOptions, required: requiredOptions } = + useAvailableOptions(item, actionOptionsConfig) type Item = NonNullable const typeDictionary: Record = { + free_gift: "Free gift", percentage: "Percentage discount", fixed_amount: "Fixed amount", fixed_price: "Fixed price", @@ -61,6 +60,55 @@ export function ActionListItem({ const pathPrefix = `rules.${selectedRuleIndex}.actions.${index}` + const setDefaultOptionFor = (optionName: string) => { + switch (optionName) { + case "round": + setPath(`${pathPrefix}.round`, true) + break + case "apply_on": + setPath(`${pathPrefix}.apply_on`, null, true) + break + case "discount_mode": + setPath(`${pathPrefix}.discount_mode`, "default") + break + case "limit": + setPath(`${pathPrefix}.limit`, {}) + break + case "aggregation": + setPath(`${pathPrefix}.aggregation`, {}) + break + case "bundle": + setPath(`${pathPrefix}.bundle`, { + type: "balanced", + }) + break + case "quantity": + setPath(`${pathPrefix}.quantity`, null, true) + break + case "identifiers": { + setPath(`${pathPrefix}.identifiers`, { + "": [], + }) + break + } + } + } + + useEffect( + function ensureRequiredOptions() { + if (item == null) { + return + } + + requiredOptions.forEach((option) => { + if (!(option.name in item)) { + setDefaultOptionFor(option.name) + } + }) + }, + [item, requiredOptions, pathPrefix], + ) + return (
{ // Set default values based on option type - switch (option.name) { - case "round": - setPath(`${pathPrefix}.round`, true) - break - case "apply_on": - setPath(`${pathPrefix}.apply_on`, null, true) - break - case "discount_mode": - setPath(`${pathPrefix}.discount_mode`, "default") - break - case "limit": - setPath(`${pathPrefix}.limit`, {}) - break - case "aggregation": - setPath(`${pathPrefix}.aggregation`, {}) - break - case "bundle": - setPath(`${pathPrefix}.bundle`, { - type: "balanced", - }) - break - case "quantity": - setPath(`${pathPrefix}.quantity`, 1) - break - } + setDefaultOptionFor(option.name) }} label={option.label} key={`option-${option.name}`} @@ -154,7 +178,14 @@ export function ActionListItem({ }))} onSelect={(selected) => { if (isSingleValueSelected(selected)) { - setPath(`${pathPrefix}.type`, selected.value) + setPath(`${pathPrefix}`, { + type: selected.value, + groups: item?.groups, + selector: + selected.value === "free_gift" + ? "order.line_items" + : item?.selector, + }) } }} /> @@ -177,13 +208,21 @@ function ActionSelector({ const { setPath, schemaType } = useRuleEngine() const { t } = useTranslation() - const initialValues = actionPaths[schemaType].map((field) => ({ - value: field, - label: (t(`resource_paths.${field}`) as string).replace( - "resource_paths.", - "", - ), - })) + const initialValues = actionPaths[schemaType] + .map((field) => ({ + value: field, + label: (t(`resource_paths.${field}`) as string).replace( + "resource_paths.", + "", + ), + })) + .filter((field) => { + if (item?.type !== "free_gift") { + return true + } + + return field.value === "order.line_items" + }) const name = `${pathPrefix}.selector` diff --git a/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionValue.tsx b/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionValue.tsx index 55702cf4..15585615 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionValue.tsx +++ b/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionValue.tsx @@ -124,6 +124,10 @@ export function ActionValue({ ) } + case "free_gift": { + return null + } + default: { return expectNever(item) } diff --git a/packages/app-elements/src/ui/forms/RuleEngine/Action/index.tsx b/packages/app-elements/src/ui/forms/RuleEngine/Action/index.tsx index 489d246b..e4e9969a 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/Action/index.tsx +++ b/packages/app-elements/src/ui/forms/RuleEngine/Action/index.tsx @@ -43,7 +43,7 @@ export function Action({ setPath( `rules.${selectedRuleIndex}.actions.${actions?.length ?? 0}`, { - selector: "order", + selector: "order.line_items", }, ) }} diff --git a/packages/app-elements/src/ui/forms/RuleEngine/Condition/ValueComponents/InputResourceSelector.tsx b/packages/app-elements/src/ui/forms/RuleEngine/Condition/ValueComponents/InputResourceSelector.tsx index 7bd4a875..be42cb26 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/Condition/ValueComponents/InputResourceSelector.tsx +++ b/packages/app-elements/src/ui/forms/RuleEngine/Condition/ValueComponents/InputResourceSelector.tsx @@ -1,14 +1,10 @@ -import type { QueryParamsList, Tag } from "@commercelayer/sdk" -import { uniqBy } from "lodash-es" import type React from "react" -import { useCoreApi, useCoreSdkProvider } from "#providers/CoreSdkProvider" import { - InputSelect, - type InputSelectValue, isMultiValueSelected, isSingleValueSelected, type PossibleSelectValue, } from "#ui/forms/InputSelect" +import { InputResourceSelector as GenericInputResourceSelector } from "../../InputResourceSelector" import { useRuleEngine } from "../../RuleEngineContext" import type { ItemWithValue } from "../../utils" import { getResourceType, type useResourcePathInfos } from "../hooks" @@ -19,70 +15,14 @@ export const InputResourceSelector: React.FC<{ infos: ReturnType["infos"] onSelect?: (selected: PossibleSelectValue) => void }> = ({ value, pathKey, infos, onSelect }) => { - const { sdkClient } = useCoreSdkProvider() - const { setPath } = useRuleEngine() - const key = infos?.field?.name ?? "id" - - const resource = getResourceType(infos?.resource?.id) - - const { data } = useCoreApi( - resource, - "list", - infos?.resource?.id == null ? null : [getParams({ value: "" })], - ) - - const { data: selectedData, isLoading: isLoadingSelectedData } = useCoreApi( - resource, - "list", - infos?.resource?.id == null - ? null - : [ - { - ...getParams({ value: "" }), - filters: { - id_in: Array.isArray(value) ? value : [value], - }, - }, - ], - ) - - const initialValues = uniqBy([...(selectedData ?? []), ...(data ?? [])], "id") - - function getValue(value: ItemWithValue["value"]): InputSelectValue { - return { - label: - initialValues?.find((item) => item.id === value.toString())?.name ?? - `${isLoadingSelectedData ? "" : "⚠️ "}${value.toString()}`, - value: value.toString(), - } - } - return ( - 25 - ? "Type to search for more options." - : undefined - } - initialValues={toInputSelectValues(initialValues ?? [], key)} - loadAsyncValues={async (value) => { - const items = await sdkClient[resource].list(getParams({ value })) - - return toInputSelectValues(items, key) - }} + value={value} onSelect={(selected) => { onSelect?.(selected) if (isMultiValueSelected(selected)) { @@ -101,26 +41,3 @@ export const InputResourceSelector: React.FC<{ /> ) } - -function getParams({ value }: { value: string }): QueryParamsList { - return { - pageSize: 25, - sort: { - name: "asc", - }, - filters: { - name_cont: value, - }, - } -} - -function toInputSelectValues( - items: Array<{ name: string }>, - key: string, -): InputSelectValue[] { - return items.map((item) => ({ - label: item.name, - // @ts-expect-error TODO: fix this - value: item[key], - })) -} diff --git a/packages/app-elements/src/ui/forms/RuleEngine/Condition/hooks.tsx b/packages/app-elements/src/ui/forms/RuleEngine/Condition/hooks.tsx index d7ed4550..90dcd4ce 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/Condition/hooks.tsx +++ b/packages/app-elements/src/ui/forms/RuleEngine/Condition/hooks.tsx @@ -1,4 +1,4 @@ -import type { ResourceTypeLock } from "@commercelayer/sdk" +import type { ListableResourceType } from "@commercelayer/sdk" import { compact, uniq } from "lodash-es" import { useEffect, useState } from "react" import { atPath } from "#ui/forms/CodeEditor/fetchCoreResourcesSuggestions" @@ -12,7 +12,8 @@ const selectableResources = { tag: "tags", sku: "skus", sku_list: "sku_lists", -} as const satisfies Record + bundle: "bundles", +} as const satisfies Record export function getResourceType(resourceId: string | undefined) { return selectableResources[resourceId as keyof typeof selectableResources] diff --git a/packages/app-elements/src/ui/forms/RuleEngine/InputResourceSelector.tsx b/packages/app-elements/src/ui/forms/RuleEngine/InputResourceSelector.tsx new file mode 100644 index 00000000..d1ff1a27 --- /dev/null +++ b/packages/app-elements/src/ui/forms/RuleEngine/InputResourceSelector.tsx @@ -0,0 +1,115 @@ +import type { + ListableResourceType, + QueryParamsList, + Tag, +} from "@commercelayer/sdk" +import { uniqBy } from "lodash-es" +import type React from "react" +import { useCoreApi, useCoreSdkProvider } from "#providers/CoreSdkProvider" +import { + InputSelect, + type InputSelectValue, + type PossibleSelectValue, +} from "#ui/forms/InputSelect" +import type { ItemWithValue } from "./utils" + +export const InputResourceSelector: React.FC<{ + value?: ItemWithValue["value"] + resource: Extract< + ListableResourceType, + "markets" | "tags" | "skus" | "sku_lists" | "bundles" + > + resourceKey: string + isMulti?: boolean + onSelect: (selected: PossibleSelectValue) => void + isDisabled?: boolean +}> = ({ + value, + resource, + isDisabled, + resourceKey, + onSelect, + isMulti = false, +}) => { + const { sdkClient } = useCoreSdkProvider() + + const { data } = useCoreApi(resource, "list", [getParams({ value: "" })]) + + const { data: selectedData, isLoading: isLoadingSelectedData } = useCoreApi( + resource, + "list", + [ + { + ...getParams({ value: "" }), + filters: { + id_in: Array.isArray(value) ? value : [value], + }, + }, + ], + ) + + const initialValues = uniqBy([...(selectedData ?? []), ...(data ?? [])], "id") + + function getValue(value: ItemWithValue["value"]): InputSelectValue { + return { + label: + initialValues?.find((item) => item.id === value.toString())?.name ?? + `${isLoadingSelectedData ? "" : "⚠️ "}${value.toString()}`, + value: value.toString(), + } + } + + return ( + 25 + ? "Type to search for more options." + : undefined + } + initialValues={toInputSelectValues(initialValues ?? [], resourceKey)} + loadAsyncValues={async (value) => { + const items = await sdkClient[resource].list(getParams({ value })) + + return toInputSelectValues(items, resourceKey) + }} + onSelect={(selected) => { + onSelect?.(selected) + }} + /> + ) +} + +function getParams({ value }: { value: string }): QueryParamsList { + return { + pageSize: 25, + sort: { + name: "asc", + }, + filters: { + name_cont: value, + }, + } +} + +function toInputSelectValues( + items: Array<{ name: string }>, + key: string, +): InputSelectValue[] { + return items.map((item) => ({ + label: item.name, + // @ts-expect-error TODO: fix this + value: item[key], + })) +} diff --git a/packages/app-elements/src/ui/forms/RuleEngine/Options/index.tsx b/packages/app-elements/src/ui/forms/RuleEngine/Options/index.tsx index 5553535b..bf737577 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/Options/index.tsx +++ b/packages/app-elements/src/ui/forms/RuleEngine/Options/index.tsx @@ -5,8 +5,13 @@ import { Icon } from "#ui/atoms/Icon" import { Text } from "#ui/atoms/Text" import { Dropdown, DropdownDivider, DropdownItem } from "#ui/composite/Dropdown" import { Input } from "#ui/forms/Input" -import { InputSelect, isSingleValueSelected } from "#ui/forms/InputSelect" +import { + InputSelect, + isMultiValueSelected, + isSingleValueSelected, +} from "#ui/forms/InputSelect" import { useAvailableGroups } from "../Condition/hooks" +import { InputResourceSelector } from "../InputResourceSelector" import { OptionRow } from "../layout/OptionRow" import { type ManagedActionOption, @@ -31,6 +36,8 @@ export function Options({ return ( <> + + @@ -39,7 +46,6 @@ export function Options({ - ) } @@ -391,7 +397,7 @@ function AggregationsOption({ + } @@ -707,6 +713,145 @@ function ApplyOnOption({ ) } +function IdentifiersOption({ + item, + pathPrefix, +}: { + item: SchemaActionItem | SchemaConditionItem + pathPrefix: string +}) { + const optionName = "identifiers" as const + + const { setPath } = useRuleEngine() + const [rerenderKey, setRerenderKey] = useState(0) + const optionRow = useOptionRow({ item, optionName, pathPrefix }) + + if (optionRow == null || optionRow.optionConfig?.required !== true) { + return null + } + + const identifiers = optionName in item ? item.identifiers : {} + const identifierEntries = Object.entries(identifiers) + + return ( + + {identifierEntries?.map(([identifierKey, identifierValue]) => { + const resourceType = + identifierKey === "order.line_items.sku.id" + ? "skus" + : identifierKey === "order.line_items.bundle.id" + ? "bundles" + : identifierKey === "order.line_items.sku.sku_lists.id" + ? "sku_lists" + : undefined + + const allValues = optionRow.optionConfig?.values ?? [] + const filteredValues = allValues.filter(({ value }) => { + return identifierEntries.find(([key]) => key === value) == null + }) + const selectedValue = { + label: + allValues.find((v) => v.value === identifierKey)?.label ?? + identifierKey, + value: identifierKey, + } + + return ( +
+ v.value === identifierKey)?.label ?? + identifierKey, + value: identifierKey, + }} + onSelect={(selected) => { + if ( + isSingleValueSelected(selected) && + typeof selected.value === "string" && + identifierKey !== selected.value + ) { + delete identifiers[identifierKey] + setPath(`${pathPrefix}.${optionName}`, { + ...identifiers, + [selected.value]: identifierValue, + }) + } + }} + /> +
+ { + if (isMultiValueSelected(selected)) { + setPath(`${pathPrefix}.${optionName}`, { + ...identifiers, + [identifierKey]: selected + .map((s) => s.value) + .filter((s) => s != null), + }) + } + }} + /> +
+ + + + } + dropdownItems={[ + { + setPath(`${pathPrefix}.identifiers`, { + ...identifiers, + "": [], + }) + }} + />, + , + { + if (identifierEntries?.length > 1) { + setPath( + `${pathPrefix}.identifiers`, + Object.fromEntries( + identifierEntries.filter( + ([key]) => key !== identifierKey, + ), + ), + ) + } + setRerenderKey((prev) => prev + 1) + }} + />, + ]} + /> +
+ ) + })} +
+ ) +} + function useOptionRow({ item, optionName, @@ -738,6 +883,27 @@ function useOptionRow({ const CustomizedOptionRow = useCallback( ({ children }: { children: React.ReactNode }) => { + const label = optionConfig?.label ?? OPTION_LABELS[optionName] + const required = optionConfig?.required === true + + if (required) { + return ( + + {label} + + } + > + {children} + + ) + } + return ( , ]} dropdownLabel={ @@ -760,7 +927,7 @@ function useOptionRow({ size="small" className="flex gap-2 items-center" > - {optionConfig?.label ?? OPTION_LABELS[optionName]} + {label} diff --git a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.json b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.json index 7ce24710..a9c64bcd 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.json +++ b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "1.1.8", + "$id": "1.2.0", "title": "Rules for order context", "description": "Rules payload within order context for the rules engine of Commerce Layer.", "properties": { @@ -433,6 +433,45 @@ }, "required": ["type", "value", "selector"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["free_gift"], + "description": "The type of action you want to apply.", + "examples": ["free_gift"] + }, + "selector": { "$ref": "#/$defs/selector" }, + "identifier": { "$ref": "#/$defs/identifier" }, + "groups": { "$ref": "#/$defs/groups" }, + "aggregation": { "$ref": "#/$defs/aggregation" }, + "identifiers": { + "type": "object", + "description": "Object whose keys are allowed selector paths (order.line_items.sku.id, order.line_items.bundle.id, order.line_items.sku.sku_lists.id). Each value is an array of ids to match. Line items matching any (selector, id) are eligible for the free gift.", + "additionalProperties": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1 + }, + "propertyNames": { + "enum": [ + "order.line_items.sku.id", + "order.line_items.bundle.id", + "order.line_items.sku.sku_lists.id" + ] + }, + "minProperties": 1 + }, + "quantity": { + "type": "integer", + "minimum": 1, + "description": "Total number of units to discount across all matching line items. Distributed in priority order (keys, then ids, then line item order). Required." + } + }, + "required": ["type", "identifiers", "quantity"], + "additionalProperties": false } ] } @@ -632,6 +671,36 @@ ], "format": "date-time" }, + { + "type": "string", + "description": "Value to compare against the field value.", + "examples": [ + "{\"matcher\": \"eq\", \"value\": \"{\"matcher\": \"eq\", \"value\": \"{{today}}\"}\"}", + "{\"matcher\": \"not_eq\", \"value\": \"{\"matcher\": \"eq\", \"value\": \"{{today}}\"}\"}", + "{\"matcher\": \"lt\", \"value\": \"{\"matcher\": \"eq\", \"value\": \"{{today}}\"}\"}", + "{\"matcher\": \"lteq\", \"value\": \"{\"matcher\": \"eq\", \"value\": \"{{today}}\"}\"}", + "{\"matcher\": \"gt\", \"value\": \"{\"matcher\": \"eq\", \"value\": \"{{today}}\"}\"}", + "{\"matcher\": \"gteq\", \"value\": \"{\"matcher\": \"eq\", \"value\": \"{{today}}\"}\"}" + ], + "matcherTypes": [ + "eq", + "not_eq", + "lt", + "lteq", + "gt", + "gteq" + ], + "format": "date-time-dynamic" + }, + { + "type": "string", + "description": "Value to compare against the field value.", + "examples": [ + "{\"matcher\": \"gteq_lteq\", \"value\": \"{\"matcher\": \"gteq_lteq\", \"value\": \"{{range(today, 7.days)}}\"}\"}" + ], + "matcherTypes": ["gteq_lteq"], + "format": "date-time-range" + }, { "type": "boolean", "description": "Value to compare against the field value.", @@ -692,6 +761,36 @@ "is_in", "is_not_in" ] + }, + { + "type": "string", + "format": "date-time-dynamic", + "examples": [ + "{\"matcher\": \"eq\", \"value\": \"{{today}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{month(today)}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{day(today)}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{year(today)}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{order.updated_at}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{month(order.updated_at)}}\"}" + ], + "matcherTypes": [ + "eq", + "not_eq", + "lt", + "lteq", + "gt", + "gteq" + ] + }, + { + "type": "string", + "format": "date-time-range", + "examples": [ + "{\"matcher\": \"gteq_lteq\", \"value\": \"{{range(today, 7.days)}}\"}", + "{\"matcher\": \"gteq_lteq\", \"value\": \"{{range(today, 2.months)}}\"}", + "{\"matcher\": \"gteq_lteq\", \"value\": \"{{range(today, 1.years)}}\"}" + ], + "matcherTypes": ["gteq_lteq"] } ] }, @@ -709,7 +808,16 @@ "{\"matcher\": \"gteq_lteq\", \"value\": [\"2024-09-25T23:34:49Z\", \"2024-09-26T23:34:49Z\"]}", "{\"matcher\": \"gt_lteq\", \"value\": [\"2024-09-25T23:34:49Z\", \"2024-09-26T23:34:49Z\"]}", "{\"matcher\": \"gteq_lt\", \"value\": [\"2024-09-25T23:34:49Z\", \"2024-09-26T23:34:49Z\"]}", - "{\"matcher\": \"gt_lt\", \"value\": [\"2024-09-25T23:34:49Z\", \"2024-09-26T23:34:49Z\"]}" + "{\"matcher\": \"gt_lt\", \"value\": [\"2024-09-25T23:34:49Z\", \"2024-09-26T23:34:49Z\"]}", + "{\"matcher\": \"eq\", \"value\": \"{{today}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{month(today)}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{day(today)}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{year(today)}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{order.updated_at}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{month(order.updated_at)}}\"}", + "{\"matcher\": \"gteq_lteq\", \"value\": \"{{range(today, 7.days)}}\"}", + "{\"matcher\": \"gteq_lteq\", \"value\": \"{{range(today, 2.months)}}\"}", + "{\"matcher\": \"gteq_lteq\", \"value\": \"{{range(today, 1.years)}}\"}" ], "matcherTypes": [ "gt_lt", @@ -775,7 +883,20 @@ } }, "required": ["field", "value", "matcher"], - "additionalProperties": false + "additionalProperties": false, + "if": { + "properties": { + "value": { + "type": "string", + "pattern": "\\{\\{range\\s*\\(.+\\)\\}\\}" + } + }, + "required": ["value"] + }, + "then": { + "properties": { "matcher": { "const": "gteq_lteq" } }, + "required": ["matcher"] + } }, { "type": "object", @@ -830,7 +951,8 @@ "required": ["field", "matcher"], "additionalProperties": false } - ] + ], + "minItems": 1 } }, "conditions_logic": { diff --git a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.schema.ts b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.schema.ts index 91dd8418..8a4c07c8 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.schema.ts +++ b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.schema.ts @@ -454,6 +454,29 @@ export interface RulesForOrderContext { [k: string]: unknown } } + | { + /** + * The type of action you want to apply. + */ + type: "free_gift" + selector?: Selector + identifier?: Identifier + groups?: Groups + aggregation?: Aggregation + /** + * Object whose keys are allowed selector paths (order.line_items.sku.id, order.line_items.bundle.id, order.line_items.sku.sku_lists.id). Each value is an array of ids to match. Line items matching any (selector, id) are eligible for the free gift. + */ + identifiers: { + /** + * @minItems 1 + */ + [k: string]: [string, ...string[]] + } + /** + * Total number of units to discount across all matching line items. Distributed in priority order (keys, then ids, then line item order). Required. + */ + quantity: number + } )[] }[] [k: string]: unknown diff --git a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/organization_config.json b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/organization_config.json index c001a0e3..b377f407 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/organization_config.json +++ b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/organization_config.json @@ -71,6 +71,25 @@ "description": "Where streaming is enabled: none, live, test or both." } } + }, + "anomalies": { + "type": "object", + "additionalProperties": false, + "description": "Configs for anomalies alerts", + "properties": { + "orders": { + "type": "object", + "description": "Configs for anomalies alerts on orders.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enables anomaly alerts on orders." + }, + "recipients": { "$ref": "#/$defs/recipients" } + } + } + } } } } @@ -226,6 +245,10 @@ "required": ["value", "label"], "additionalProperties": false } + }, + "recipients": { + "type": "string", + "description": "Comma separated list of email recipients for the notifications (ex: test0@commercelayer.io,test1@sommercelayer.io)." } } } diff --git a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.json b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.json index 685d1a54..1f77faa4 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.json +++ b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "1.1.8", + "$id": "1.2.0", "title": "Rules for price context", "description": "Rules payload within price context for the rules engine of Commerce Layer.", "properties": { @@ -554,6 +554,36 @@ ], "format": "date-time" }, + { + "type": "string", + "description": "Value to compare against the field value.", + "examples": [ + "{\"matcher\": \"eq\", \"value\": \"{\"matcher\": \"eq\", \"value\": \"{{today}}\"}\"}", + "{\"matcher\": \"not_eq\", \"value\": \"{\"matcher\": \"eq\", \"value\": \"{{today}}\"}\"}", + "{\"matcher\": \"lt\", \"value\": \"{\"matcher\": \"eq\", \"value\": \"{{today}}\"}\"}", + "{\"matcher\": \"lteq\", \"value\": \"{\"matcher\": \"eq\", \"value\": \"{{today}}\"}\"}", + "{\"matcher\": \"gt\", \"value\": \"{\"matcher\": \"eq\", \"value\": \"{{today}}\"}\"}", + "{\"matcher\": \"gteq\", \"value\": \"{\"matcher\": \"eq\", \"value\": \"{{today}}\"}\"}" + ], + "matcherTypes": [ + "eq", + "not_eq", + "lt", + "lteq", + "gt", + "gteq" + ], + "format": "date-time-dynamic" + }, + { + "type": "string", + "description": "Value to compare against the field value.", + "examples": [ + "{\"matcher\": \"gteq_lteq\", \"value\": \"{\"matcher\": \"gteq_lteq\", \"value\": \"{{range(today, 7.days)}}\"}\"}" + ], + "matcherTypes": ["gteq_lteq"], + "format": "date-time-range" + }, { "type": "boolean", "description": "Value to compare against the field value.", @@ -614,6 +644,36 @@ "is_in", "is_not_in" ] + }, + { + "type": "string", + "format": "date-time-dynamic", + "examples": [ + "{\"matcher\": \"eq\", \"value\": \"{{today}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{month(today)}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{day(today)}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{year(today)}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{order.updated_at}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{month(order.updated_at)}}\"}" + ], + "matcherTypes": [ + "eq", + "not_eq", + "lt", + "lteq", + "gt", + "gteq" + ] + }, + { + "type": "string", + "format": "date-time-range", + "examples": [ + "{\"matcher\": \"gteq_lteq\", \"value\": \"{{range(today, 7.days)}}\"}", + "{\"matcher\": \"gteq_lteq\", \"value\": \"{{range(today, 2.months)}}\"}", + "{\"matcher\": \"gteq_lteq\", \"value\": \"{{range(today, 1.years)}}\"}" + ], + "matcherTypes": ["gteq_lteq"] } ] }, @@ -631,7 +691,16 @@ "{\"matcher\": \"gteq_lteq\", \"value\": [\"2024-09-25T23:34:49Z\", \"2024-09-26T23:34:49Z\"]}", "{\"matcher\": \"gt_lteq\", \"value\": [\"2024-09-25T23:34:49Z\", \"2024-09-26T23:34:49Z\"]}", "{\"matcher\": \"gteq_lt\", \"value\": [\"2024-09-25T23:34:49Z\", \"2024-09-26T23:34:49Z\"]}", - "{\"matcher\": \"gt_lt\", \"value\": [\"2024-09-25T23:34:49Z\", \"2024-09-26T23:34:49Z\"]}" + "{\"matcher\": \"gt_lt\", \"value\": [\"2024-09-25T23:34:49Z\", \"2024-09-26T23:34:49Z\"]}", + "{\"matcher\": \"eq\", \"value\": \"{{today}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{month(today)}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{day(today)}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{year(today)}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{order.updated_at}}\"}", + "{\"matcher\": \"eq\", \"value\": \"{{month(order.updated_at)}}\"}", + "{\"matcher\": \"gteq_lteq\", \"value\": \"{{range(today, 7.days)}}\"}", + "{\"matcher\": \"gteq_lteq\", \"value\": \"{{range(today, 2.months)}}\"}", + "{\"matcher\": \"gteq_lteq\", \"value\": \"{{range(today, 1.years)}}\"}" ], "matcherTypes": [ "gt_lt", @@ -697,7 +766,20 @@ } }, "required": ["field", "value", "matcher"], - "additionalProperties": false + "additionalProperties": false, + "if": { + "properties": { + "value": { + "type": "string", + "pattern": "\\{\\{range\\s*\\(.+\\)\\}\\}" + } + }, + "required": ["value"] + }, + "then": { + "properties": { "matcher": { "const": "gteq_lteq" } }, + "required": ["matcher"] + } }, { "type": "object", @@ -752,7 +834,8 @@ "required": ["field", "matcher"], "additionalProperties": false } - ] + ], + "minItems": 1 } }, "conditions_logic": { diff --git a/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.test.ts b/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.test.ts index b50f203d..495b2f8a 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.test.ts +++ b/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.test.ts @@ -44,6 +44,7 @@ describe("parseOptionsFromSchema", () => { "limit", ], "name": "bundle", + "required": false, "valueType": "object", "values": [ { @@ -69,6 +70,7 @@ describe("parseOptionsFromSchema", () => { "label": "Apply on", "mutuallyExclusiveWith": [], "name": "apply_on", + "required": false, "valueType": "string", "values": [ { @@ -86,6 +88,7 @@ describe("parseOptionsFromSchema", () => { "label": "Discount mode", "mutuallyExclusiveWith": [], "name": "discount_mode", + "required": false, "valueType": "string", "values": [ { @@ -105,6 +108,7 @@ describe("parseOptionsFromSchema", () => { "bundle", ], "name": "limit", + "required": false, "valueType": "object", "values": [ { @@ -130,6 +134,7 @@ describe("parseOptionsFromSchema", () => { "label": "Quantity", "mutuallyExclusiveWith": [], "name": "quantity", + "required": false, "valueType": "integer", "values": undefined, }, @@ -142,6 +147,7 @@ describe("parseOptionsFromSchema", () => { "limit", ], "name": "bundle", + "required": false, "valueType": "object", "values": [ { @@ -167,6 +173,7 @@ describe("parseOptionsFromSchema", () => { "label": "Apply on", "mutuallyExclusiveWith": [], "name": "apply_on", + "required": false, "valueType": "string", "values": [ { @@ -184,6 +191,7 @@ describe("parseOptionsFromSchema", () => { "label": "Discount mode", "mutuallyExclusiveWith": [], "name": "discount_mode", + "required": false, "valueType": "string", "values": [ { @@ -203,6 +211,7 @@ describe("parseOptionsFromSchema", () => { "bundle", ], "name": "limit", + "required": false, "valueType": "object", "values": [ { @@ -228,6 +237,7 @@ describe("parseOptionsFromSchema", () => { "label": "Quantity", "mutuallyExclusiveWith": [], "name": "quantity", + "required": false, "valueType": "integer", "values": undefined, }, @@ -249,6 +259,7 @@ describe("parseOptionsFromSchema", () => { "limit", ], "name": "bundle", + "required": false, "valueType": "object", "values": [ { @@ -274,6 +285,7 @@ describe("parseOptionsFromSchema", () => { "label": "Apply on", "mutuallyExclusiveWith": [], "name": "apply_on", + "required": false, "valueType": "string", "values": [ { @@ -293,6 +305,7 @@ describe("parseOptionsFromSchema", () => { "bundle", ], "name": "limit", + "required": false, "valueType": "object", "values": [ { @@ -318,6 +331,7 @@ describe("parseOptionsFromSchema", () => { "label": "Quantity", "mutuallyExclusiveWith": [], "name": "quantity", + "required": false, "valueType": "integer", "values": undefined, }, @@ -330,6 +344,7 @@ describe("parseOptionsFromSchema", () => { "limit", ], "name": "bundle", + "required": false, "valueType": "object", "values": [ { @@ -355,6 +370,7 @@ describe("parseOptionsFromSchema", () => { "label": "Apply on", "mutuallyExclusiveWith": [], "name": "apply_on", + "required": false, "valueType": "string", "values": [ { @@ -374,6 +390,7 @@ describe("parseOptionsFromSchema", () => { "bundle", ], "name": "limit", + "required": false, "valueType": "object", "values": [ { @@ -399,6 +416,82 @@ describe("parseOptionsFromSchema", () => { "label": "Quantity", "mutuallyExclusiveWith": [], "name": "quantity", + "required": false, + "valueType": "integer", + "values": undefined, + }, + ], + "order.line_items.adjustment": [], + "order.line_items.bundle": [], + "order.line_items.gift_card": [], + "order.line_items.line_item_options": [], + "order.line_items.payment_method": [], + "order.line_items.shipment": [], + "order.line_items.sku": [], + }, + "free_gift": { + "order": [ + { + "description": "Object whose keys are allowed selector paths (order.line_items.sku.id, order.line_items.bundle.id, order.line_items.sku.sku_lists.id). Each value is an array of ids to match. Line items matching any (selector, id) are eligible for the free gift.", + "label": "Identifiers", + "mutuallyExclusiveWith": [], + "name": "identifiers", + "required": true, + "valueType": "object", + "values": [ + { + "label": "SKU", + "value": "order.line_items.sku.id", + }, + { + "label": "Bundle", + "value": "order.line_items.bundle.id", + }, + { + "label": "SKU list", + "value": "order.line_items.sku.sku_lists.id", + }, + ], + }, + { + "description": "Total number of units to discount across all matching line items. Distributed in priority order (keys, then ids, then line item order). Required.", + "label": "Quantity", + "mutuallyExclusiveWith": [], + "name": "quantity", + "required": true, + "valueType": "integer", + "values": undefined, + }, + ], + "order.line_items": [ + { + "description": "Object whose keys are allowed selector paths (order.line_items.sku.id, order.line_items.bundle.id, order.line_items.sku.sku_lists.id). Each value is an array of ids to match. Line items matching any (selector, id) are eligible for the free gift.", + "label": "Identifiers", + "mutuallyExclusiveWith": [], + "name": "identifiers", + "required": true, + "valueType": "object", + "values": [ + { + "label": "SKU", + "value": "order.line_items.sku.id", + }, + { + "label": "Bundle", + "value": "order.line_items.bundle.id", + }, + { + "label": "SKU list", + "value": "order.line_items.sku.sku_lists.id", + }, + ], + }, + { + "description": "Total number of units to discount across all matching line items. Distributed in priority order (keys, then ids, then line item order). Required.", + "label": "Quantity", + "mutuallyExclusiveWith": [], + "name": "quantity", + "required": true, "valueType": "integer", "values": undefined, }, @@ -418,6 +511,7 @@ describe("parseOptionsFromSchema", () => { "label": "Round", "mutuallyExclusiveWith": [], "name": "round", + "required": false, "valueType": "boolean", "values": undefined, }, @@ -426,6 +520,7 @@ describe("parseOptionsFromSchema", () => { "label": "Apply on", "mutuallyExclusiveWith": [], "name": "apply_on", + "required": false, "valueType": "string", "values": [ { @@ -445,6 +540,7 @@ describe("parseOptionsFromSchema", () => { "limit", ], "name": "bundle", + "required": false, "valueType": "object", "values": [ { @@ -472,6 +568,7 @@ describe("parseOptionsFromSchema", () => { "bundle", ], "name": "limit", + "required": false, "valueType": "object", "values": [ { @@ -497,6 +594,7 @@ describe("parseOptionsFromSchema", () => { "label": "Quantity", "mutuallyExclusiveWith": [], "name": "quantity", + "required": false, "valueType": "integer", "values": undefined, }, @@ -507,6 +605,7 @@ describe("parseOptionsFromSchema", () => { "label": "Round", "mutuallyExclusiveWith": [], "name": "round", + "required": false, "valueType": "boolean", "values": undefined, }, @@ -515,6 +614,7 @@ describe("parseOptionsFromSchema", () => { "label": "Apply on", "mutuallyExclusiveWith": [], "name": "apply_on", + "required": false, "valueType": "string", "values": [ { @@ -534,6 +634,7 @@ describe("parseOptionsFromSchema", () => { "limit", ], "name": "bundle", + "required": false, "valueType": "object", "values": [ { @@ -561,6 +662,7 @@ describe("parseOptionsFromSchema", () => { "bundle", ], "name": "limit", + "required": false, "valueType": "object", "values": [ { @@ -586,6 +688,7 @@ describe("parseOptionsFromSchema", () => { "label": "Quantity", "mutuallyExclusiveWith": [], "name": "quantity", + "required": false, "valueType": "integer", "values": undefined, }, @@ -605,6 +708,7 @@ describe("parseOptionsFromSchema", () => { "label": "Scope", "mutuallyExclusiveWith": [], "name": "scope", + "required": false, "valueType": "string", "values": [ { @@ -622,6 +726,7 @@ describe("parseOptionsFromSchema", () => { "label": "Group", "mutuallyExclusiveWith": [], "name": "group", + "required": false, "valueType": "string", "values": undefined, }, @@ -630,6 +735,7 @@ describe("parseOptionsFromSchema", () => { "label": "Aggregations", "mutuallyExclusiveWith": [], "name": "aggregations", + "required": false, "valueType": "array", "values": [ { @@ -671,6 +777,7 @@ describe("parseOptionsFromSchema", () => { "label": "Apply on", "mutuallyExclusiveWith": [], "name": "apply_on", + "required": false, "valueType": "string", "values": [ { @@ -692,6 +799,7 @@ describe("parseOptionsFromSchema", () => { "label": "Apply on", "mutuallyExclusiveWith": [], "name": "apply_on", + "required": false, "valueType": "string", "values": [ { @@ -713,6 +821,7 @@ describe("parseOptionsFromSchema", () => { "label": "Apply on", "mutuallyExclusiveWith": [], "name": "apply_on", + "required": false, "valueType": "string", "values": [ { @@ -734,6 +843,7 @@ describe("parseOptionsFromSchema", () => { "label": "Scope", "mutuallyExclusiveWith": [], "name": "scope", + "required": false, "valueType": "string", "values": [ { @@ -751,6 +861,7 @@ describe("parseOptionsFromSchema", () => { "label": "Group", "mutuallyExclusiveWith": [], "name": "group", + "required": false, "valueType": "string", "values": undefined, }, @@ -825,6 +936,7 @@ describe("useAvailableOptions", () => { ], "current": [], "disabled": [], + "required": [], } `) }) @@ -882,6 +994,7 @@ describe("useAvailableOptions", () => { "name": "limit", }, ], + "required": [], } `) }) @@ -938,6 +1051,7 @@ describe("useAvailableOptions", () => { "name": "bundle", }, ], + "required": [], } `) }) @@ -979,6 +1093,7 @@ describe("useAvailableOptions", () => { "round", ], "disabled": [], + "required": [], } `) }) @@ -999,6 +1114,7 @@ describe("useAvailableOptions", () => { "available": [], "current": [], "disabled": [], + "required": [], } `) }) diff --git a/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.ts b/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.ts index 219d33fd..ab62a104 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.ts +++ b/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.ts @@ -18,6 +18,8 @@ export interface OptionConfig { description?: string /** Predefined values from configuration */ values?: Array<{ label: string; value: string; meta?: Record }> + /** Whether this option is required and must always be present */ + required?: boolean } /** @@ -40,6 +42,11 @@ const configuration = { round: true, quantity: true, discount_mode: true, + identifiers: [ + { label: "SKU", value: "order.line_items.sku.id" }, + { label: "Bundle", value: "order.line_items.bundle.id" }, + { label: "SKU list", value: "order.line_items.sku.sku_lists.id" }, + ], apply_on: [ { label: "Subtotal amount", value: "subtotal_amount_cents" }, { label: "Total amount", value: "total_amount_cents" }, @@ -85,6 +92,11 @@ const configuration = { round: true, quantity: true, discount_mode: true, + identifiers: [ + { label: "SKU", value: "order.line_items.sku.id" }, + { label: "Bundle", value: "order.line_items.bundle.id" }, + { label: "SKU list", value: "order.line_items.sku.sku_lists.id" }, + ], apply_on: [ { label: "Unit amount", value: "unit_amount_cents" }, { label: "Compare at amount", value: "compare_at_amount_cents" }, @@ -238,6 +250,7 @@ const MANAGED_ACTION_OPTIONS = [ "aggregation", "bundle", "quantity", + "identifiers", ] as const const MANAGED_CONDITION_OPTIONS = ["group", "scope", "aggregations"] as const @@ -262,6 +275,7 @@ export const OPTION_LABELS: Record< aggregations: "Aggregations", group: "Group", quantity: "Quantity", + identifiers: "Identifiers", } as const /** @@ -399,6 +413,8 @@ function parseConditionOptions( meta?: Record }>) : option.values, + // Conditions are never required (only actions can be required) + required: false, } }) } catch (error) { @@ -473,7 +489,7 @@ function buildOptions( ): OptionConfig[] { const options: OptionConfig[] = [] - for (const [optionName, { appearsIn }] of optionsMap.entries()) { + for (const [optionName, { appearsIn, requiredIn }] of optionsMap.entries()) { const mutuallyExclusiveWith = findMutuallyExclusiveOptions( optionName, appearsIn, @@ -501,6 +517,7 @@ function buildOptions( optionName as ManagedActionOption | ManagedConditionOption ], mutuallyExclusiveWith, + required: requiredIn.size > 0, ...metadata, }) } @@ -587,6 +604,8 @@ interface OptionAvailability { disabled: OptionConfig[] /** Options that are currently set */ current: string[] + /** Options that are required (must always be present) */ + required: OptionConfig[] } /** @@ -601,6 +620,7 @@ export function useAvailableOptions( available: [], disabled: [], current: [], + required: [], } } @@ -641,9 +661,13 @@ export function useAvailableOptions( } } + // Get required options (including those not currently set) + const requiredOptions = optionsConfig.filter((opt) => opt.required === true) + return { available, disabled, current: currentOptions, + required: requiredOptions, } } diff --git a/packages/app-elements/src/ui/forms/RuleEngine/utils.ts b/packages/app-elements/src/ui/forms/RuleEngine/utils.ts index 746aca5d..e5e9de16 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/utils.ts +++ b/packages/app-elements/src/ui/forms/RuleEngine/utils.ts @@ -60,7 +60,7 @@ export async function fetchJsonSchema( : void > { if (domain === "localhost") { - domain = "commercelayer.io" + domain = "commercelayer.co" } switch (jsonSchema) { From 97b5a32c77644d357d1fa01fe83054018d6f8516 Mon Sep 17 00:00:00 2001 From: Marco Montalbano Date: Fri, 13 Feb 2026 14:26:35 +0100 Subject: [PATCH 3/4] chore(RuleEngine): update layout for free-gift identifiers --- .../RuleEngine/Action/ActionListItem.tsx | 4 +- .../src/ui/forms/RuleEngine/Options/index.tsx | 165 +++++------------- 2 files changed, 49 insertions(+), 120 deletions(-) diff --git a/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionListItem.tsx b/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionListItem.tsx index d90a8b2e..00275e35 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionListItem.tsx +++ b/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionListItem.tsx @@ -86,9 +86,7 @@ export function ActionListItem({ setPath(`${pathPrefix}.quantity`, null, true) break case "identifiers": { - setPath(`${pathPrefix}.identifiers`, { - "": [], - }) + setPath(`${pathPrefix}.identifiers`, {}) break } } diff --git a/packages/app-elements/src/ui/forms/RuleEngine/Options/index.tsx b/packages/app-elements/src/ui/forms/RuleEngine/Options/index.tsx index bf737577..f04d62fd 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/Options/index.tsx +++ b/packages/app-elements/src/ui/forms/RuleEngine/Options/index.tsx @@ -723,133 +723,64 @@ function IdentifiersOption({ const optionName = "identifiers" as const const { setPath } = useRuleEngine() - const [rerenderKey, setRerenderKey] = useState(0) + // const [rerenderKey, setRerenderKey] = useState(0) const optionRow = useOptionRow({ item, optionName, pathPrefix }) if (optionRow == null || optionRow.optionConfig?.required !== true) { return null } - const identifiers = optionName in item ? item.identifiers : {} - const identifierEntries = Object.entries(identifiers) + const selectedIdentifiers = optionName in item ? item.identifiers : {} - return ( - - {identifierEntries?.map(([identifierKey, identifierValue]) => { - const resourceType = - identifierKey === "order.line_items.sku.id" - ? "skus" - : identifierKey === "order.line_items.bundle.id" - ? "bundles" - : identifierKey === "order.line_items.sku.sku_lists.id" - ? "sku_lists" - : undefined + const allValues = optionRow.optionConfig?.values ?? [] - const allValues = optionRow.optionConfig?.values ?? [] - const filteredValues = allValues.filter(({ value }) => { - return identifierEntries.find(([key]) => key === value) == null - }) - const selectedValue = { - label: - allValues.find((v) => v.value === identifierKey)?.label ?? - identifierKey, - value: identifierKey, + return allValues.map(({ label, value }) => { + const resourceType = + value === "order.line_items.sku.id" + ? "skus" + : value === "order.line_items.bundle.id" + ? "bundles" + : value === "order.line_items.sku.sku_lists.id" + ? "sku_lists" + : undefined + if (resourceType == null) { + return null + } + return ( + + Free {label} + } - - return ( -
- v.value === identifierKey)?.label ?? - identifierKey, - value: identifierKey, - }} - onSelect={(selected) => { - if ( - isSingleValueSelected(selected) && - typeof selected.value === "string" && - identifierKey !== selected.value - ) { - delete identifiers[identifierKey] - setPath(`${pathPrefix}.${optionName}`, { - ...identifiers, - [selected.value]: identifierValue, - }) - } - }} - /> -
- { - if (isMultiValueSelected(selected)) { - setPath(`${pathPrefix}.${optionName}`, { - ...identifiers, - [identifierKey]: selected - .map((s) => s.value) - .filter((s) => s != null), - }) - } - }} - /> -
- - - + key={value} + > + { + console.log("selected", selected) + if (isMultiValueSelected(selected)) { + if (selected.length > 0) { + setPath(`${pathPrefix}.${optionName}`, { + ...selectedIdentifiers, + [value]: selected + .map((s) => s.value) + .filter((s) => s != null), + }) + } else { + const updatedIdentifiers = { ...selectedIdentifiers } + delete updatedIdentifiers[value] + setPath(`${pathPrefix}.${optionName}`, updatedIdentifiers) } - dropdownItems={[ - { - setPath(`${pathPrefix}.identifiers`, { - ...identifiers, - "": [], - }) - }} - />, - , - { - if (identifierEntries?.length > 1) { - setPath( - `${pathPrefix}.identifiers`, - Object.fromEntries( - identifierEntries.filter( - ([key]) => key !== identifierKey, - ), - ), - ) - } - setRerenderKey((prev) => prev + 1) - }} - />, - ]} - /> -
- ) - })} -
- ) + } + }} + /> +
+ ) + }) } function useOptionRow({ From bb38d5095f66c1ebbcfc95ad31658cb706aa6f8d Mon Sep 17 00:00:00 2001 From: Marco Montalbano Date: Wed, 18 Feb 2026 12:33:14 +0100 Subject: [PATCH 4/4] chore(RuleEngine): make rule engine fully dynamic rendered given the json-schema --- .../src/providers/I18NProvider.tsx | 1 + .../forms/CodeEditor/CodeEditorComponent.tsx | 1 - .../src/ui/forms/InputSelect/overrides.tsx | 2 +- .../RuleEngine/Action/ActionListItem.tsx | 179 +-- .../forms/RuleEngine/Action/ActionValue.tsx | 6 + .../src/ui/forms/RuleEngine/Action/index.tsx | 5 +- .../Condition/ConditionListItem.tsx | 4 +- .../RuleEngine/Options/ActionOptions.tsx | 641 +++++++++ .../RuleEngine/Options/ConditionOptions.tsx | 200 +++ .../ui/forms/RuleEngine/Options/common.tsx | 222 +++ .../src/ui/forms/RuleEngine/Options/index.tsx | 887 +----------- .../ui/forms/RuleEngine/RuleEngineContext.tsx | 26 +- .../RuleEngine/json_schema/order_rules.json | 20 +- .../json_schema/order_rules.schema.ts | 22 +- .../RuleEngine/json_schema/price_rules.json | 12 +- .../json_schema/price_rules.schema.ts | 12 +- .../ui/forms/RuleEngine/layout/OptionRow.tsx | 4 +- .../ui/forms/RuleEngine/optionsConfig.test.ts | 1206 +++++++++++++++-- .../src/ui/forms/RuleEngine/optionsConfig.ts | 98 +- packages/docs/public/mockServiceWorker.js | 221 +-- 20 files changed, 2467 insertions(+), 1302 deletions(-) create mode 100644 packages/app-elements/src/ui/forms/RuleEngine/Options/ActionOptions.tsx create mode 100644 packages/app-elements/src/ui/forms/RuleEngine/Options/ConditionOptions.tsx create mode 100644 packages/app-elements/src/ui/forms/RuleEngine/Options/common.tsx diff --git a/packages/app-elements/src/providers/I18NProvider.tsx b/packages/app-elements/src/providers/I18NProvider.tsx index 67beb37d..0ab6e24a 100644 --- a/packages/app-elements/src/providers/I18NProvider.tsx +++ b/packages/app-elements/src/providers/I18NProvider.tsx @@ -91,6 +91,7 @@ const initI18n = async ( load: "languageOnly", lng: localeCode, fallbackLng: i18nLocales[0], + showSupportNotice: false, react: { useSuspense: true, }, diff --git a/packages/app-elements/src/ui/forms/CodeEditor/CodeEditorComponent.tsx b/packages/app-elements/src/ui/forms/CodeEditor/CodeEditorComponent.tsx index 8a30c92a..c9aa45d4 100644 --- a/packages/app-elements/src/ui/forms/CodeEditor/CodeEditorComponent.tsx +++ b/packages/app-elements/src/ui/forms/CodeEditor/CodeEditorComponent.tsx @@ -178,7 +178,6 @@ export const CodeEditor = forwardRef< const schema = await fetchJsonSchema(jsonSchema, domain).then( (json) => { if (json != null) { - console.log(json) return clearExamples(json) } diff --git a/packages/app-elements/src/ui/forms/InputSelect/overrides.tsx b/packages/app-elements/src/ui/forms/InputSelect/overrides.tsx index 148a5cec..8cabf946 100644 --- a/packages/app-elements/src/ui/forms/InputSelect/overrides.tsx +++ b/packages/app-elements/src/ui/forms/InputSelect/overrides.tsx @@ -30,7 +30,7 @@ function DropdownIndicator( {/** biome-ignore lint/a11y/noSvgWithoutTitle: Don't need to add a title */} { switch (optionName) { + case "selector": + setPath( + `${pathPrefix}.selector`, + schemaType === "order-rules" + ? "order.line_items" + : schemaType === "price-rules" + ? "price" + : null, + true, + ) + break + case "groups": + setPath(`${pathPrefix}.groups`, []) + break case "round": setPath(`${pathPrefix}.round`, true) break @@ -125,9 +134,7 @@ export function ActionListItem({ } options={ <> - - - + {availableOptions.length > 0 && ( ) } - -function ActionSelector({ - item, - pathPrefix, -}: { - item: SchemaActionItem | null - pathPrefix: string -}) { - const { setPath, schemaType } = useRuleEngine() - const { t } = useTranslation() - - const initialValues = actionPaths[schemaType] - .map((field) => ({ - value: field, - label: (t(`resource_paths.${field}`) as string).replace( - "resource_paths.", - "", - ), - })) - .filter((field) => { - if (item?.type !== "free_gift") { - return true - } - - return field.value === "order.line_items" - }) - - const name = `${pathPrefix}.selector` - - return ( - - Apply to - - } - > - c.value === item.selector) ?? { - value: item.selector, - label: item.selector, - }) - } - onSelect={async (selection) => { - if (isSingleValueSelected(selection)) { - setPath(name, selection.value) - setPath(`${pathPrefix}.apply_on`, null) - } - }} - /> - - ) -} - -function ActionGroups({ - item, - pathPrefix, -}: { - item: SchemaActionItem | null - pathPrefix: string -}) { - const { setPath, schemaType } = useRuleEngine() - const availableGroups = useAvailableGroups() - - useEffect(() => { - if (availableGroups.length === 0 && (item?.groups ?? []).length > 0) { - setPath(`${pathPrefix}.groups`, []) - } - }, [availableGroups]) - - if (availableGroups.length <= 0 && (item?.groups ?? []).length <= 0) { - return null - } - - return ( - - Groups - - } - > - ({ - label: availableGroups.includes(groups) - ? groups - : `⚠️   ${groups}`, - value: groups, - })) - : undefined - } - initialValues={availableGroups.map((group) => ({ - value: group, - label: group, - }))} - onSelect={(selected) => { - if (isMultiValueSelected(selected)) { - setPath( - `${pathPrefix}.groups`, - selected.map((s) => s.value), - ) - - if (schemaType === "order-rules" && selected.length > 0) { - setPath(`${pathPrefix}.selector`, "order.line_items") - } - } - }} - /> - - ) -} - -const actionPaths = { - "order-rules": [ - "order", - "order.line_items", - "order.line_items.line_item_options", - "order.line_items.sku", - "order.line_items.bundle", - "order.line_items.shipment", - "order.line_items.payment_method", - "order.line_items.adjustment", - "order.line_items.gift_card", - ] as const, - "price-rules": ["price"] as const, -} satisfies Record diff --git a/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionValue.tsx b/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionValue.tsx index 15585615..de2765f7 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionValue.tsx +++ b/packages/app-elements/src/ui/forms/RuleEngine/Action/ActionValue.tsx @@ -19,6 +19,7 @@ export function ActionValue({ return (
{ setPath( `rules.${selectedRuleIndex}.actions.${actions?.length ?? 0}`, - { - selector: "order.line_items", - }, + null, + true, ) }} > diff --git a/packages/app-elements/src/ui/forms/RuleEngine/Condition/ConditionListItem.tsx b/packages/app-elements/src/ui/forms/RuleEngine/Condition/ConditionListItem.tsx index fe1258ac..82a975be 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/Condition/ConditionListItem.tsx +++ b/packages/app-elements/src/ui/forms/RuleEngine/Condition/ConditionListItem.tsx @@ -5,7 +5,7 @@ import { Text } from "#ui/atoms/Text" import { Dropdown, DropdownDivider, DropdownItem } from "#ui/composite/Dropdown" import { InputResourcePath } from "../InputResourcePath" import { ListItemContainer } from "../layout/ListItemContainer" -import { Options } from "../Options" +import { ConditionOptions } from "../Options" import { useAvailableOptions } from "../optionsConfig" import { useRuleEngine } from "../RuleEngineContext" import type { SchemaConditionItem } from "../utils" @@ -80,7 +80,7 @@ export function ConditionListItem({ !isEmpty(item?.matcher) && ( <> - + {availableOptions.length > 0 && ( + + + + + + + + + + + + ) +} + +function SelectorOption({ + item, + pathPrefix, +}: { + item: SchemaActionItem + pathPrefix: string +}) { + const optionName = "selector" as const + + const { setPath, schemaType } = useRuleEngine() + const { t } = useTranslation() + + const optionRow = useOptionRow({ item, optionName, pathPrefix }) + + if (!(optionName in item) || optionRow == null) { + return null + } + + const initialValues = actionPaths[schemaType].map((field) => ({ + value: field, + label: (t(`resource_paths.${field}`) as string).replace( + "resource_paths.", + "", + ), + })) + + const name = `${pathPrefix}.${optionName}` + + const value = + optionName in item + ? item?.selector == null + ? undefined + : (initialValues.find((c) => c.value === item.selector) ?? { + value: item.selector, + label: item.selector, + }) + : undefined + + return ( + + { + if (isSingleValueSelected(selection)) { + setPath(name, selection.value) + setPath(`${pathPrefix}.apply_on`, null) + } + }} + /> + + ) +} + +function GroupsOption({ + item, + pathPrefix, +}: { + item: SchemaActionItem + pathPrefix: string +}) { + const optionName = "groups" as const + + const { setPath, schemaType } = useRuleEngine() + const availableGroups = useAvailableGroups() + + const optionRow = useOptionRow({ item, optionName, pathPrefix }) + + if (!(optionName in item) || optionRow == null) { + return null + } + + if ( + availableGroups.length <= 0 && + ("groups" in item && item.groups != null ? item.groups : []).length <= 0 + ) { + return null + } + + const value = + "groups" in item + ? item != null + ? item.groups?.map((groups) => ({ + label: availableGroups.includes(groups) ? groups : `⚠️   ${groups}`, + value: groups, + })) + : undefined + : undefined + + return ( + + ({ + value: group, + label: group, + }))} + onSelect={(selected) => { + if (isMultiValueSelected(selected)) { + setPath( + `${pathPrefix}.groups`, + selected.map((s) => s.value), + ) + + if (schemaType === "order-rules" && selected.length > 0) { + setPath(`${pathPrefix}.selector`, "order.line_items") + } + } + }} + /> + + ) +} + +function RoundOption({ + item, + pathPrefix, +}: { + item: SchemaActionItem + pathPrefix: string +}) { + const optionName = "round" as const + + const { setPath } = useRuleEngine() + const optionRow = useOptionRow({ item, optionName, pathPrefix }) + + if (!(optionName in item) || optionRow == null) { + return null + } + + const initialValues = [ + { label: "Yes", value: true }, + { label: "No", value: false }, + ] + + const defaultValue = initialValues.find((v) => v.value === item[optionName]) + + return ( + + { + if (isSingleValueSelected(selected)) { + setPath(`${pathPrefix}.${optionName}`, selected.value) + } + }} + /> + + ) +} + +function QuantityOption({ + item, + pathPrefix, +}: { + item: SchemaActionItem + pathPrefix: string +}) { + const optionName = "quantity" as const + + const { setPath } = useRuleEngine() + const optionRow = useOptionRow({ item, optionName, pathPrefix }) + + if (!(optionName in item) || optionRow == null) { + return null + } + + const defaultValue = item[optionName] + + return ( + + { + const value = parseInt(e.currentTarget.value, 10) + setPath(`${pathPrefix}.${optionName}`, value) + }} + /> + + ) +} + +function LimitOption({ + item, + pathPrefix, +}: { + item: SchemaActionItem + pathPrefix: string +}) { + const optionName = "limit" as const + + const { setPath } = useRuleEngine() + const optionRow = useOptionRow({ item, optionName, pathPrefix }) + + if (!(optionName in item) || optionRow == null) { + return null + } + + const defaultValue = optionRow.optionConfig?.values?.find((entry) => + isEqual(item.limit?.sort, entry.meta), + ) + + return ( + +
+ { + const value = parseInt(e.currentTarget.value, 10) + setPath(`${pathPrefix}.${optionName}.value`, value) + }} + defaultValue={item.limit?.value} + /> + ({ + label: entry.label, + meta: entry.meta, + value: JSON.stringify(entry.meta), + })) ?? [] + } + onSelect={(selected) => { + if (isSingleValueSelected(selected)) { + setPath(`${pathPrefix}.${optionName}.sort`, selected.meta) + } + }} + /> +
+
+ ) +} + +function AggregationOption({ + item, + pathPrefix, +}: { + item: SchemaActionItem + pathPrefix: string +}) { + const optionName = "aggregation" as const + + const optionRow = useOptionRow({ item, optionName, pathPrefix }) + + if (!(optionName in item) || optionRow == null || item[optionName] == null) { + return null + } + + return ( + + + + ) +} + +function BundleOption({ + item, + pathPrefix, +}: { + item: SchemaActionItem + pathPrefix: string +}) { + const optionName = "bundle" as const + + const { setPath } = useRuleEngine() + const optionRow = useOptionRow({ item, optionName, pathPrefix }) + + if (!(optionName in item) || optionRow == null) { + return null + } + + const defaultValue = optionRow.optionConfig?.values?.find((entry) => + isEqual(item.bundle?.sort, entry.meta), + ) + + const bundleTypes = [ + { label: "Balanced", value: "balanced" }, + { label: "Every", value: "every" }, + ] + + return ( + +
+ v.value === item.bundle?.type) ?? { + label: item.bundle.type, + value: item.bundle.type, + }) + } + onSelect={(selected) => { + if (isSingleValueSelected(selected)) { + setPath(`${pathPrefix}.${optionName}.type`, selected.value) + + if (selected.value === "balanced") { + setPath(`${pathPrefix}.${optionName}.value`, null) + } + } + }} + /> + {item.bundle?.type === "every" && ( + { + const value = parseInt(e.currentTarget.value, 10) + setPath(`${pathPrefix}.${optionName}.value`, value) + }} + defaultValue={item.bundle?.value} + /> + )} + ({ + label: entry.label, + meta: entry.meta, + value: JSON.stringify(entry.meta), + })) ?? [] + } + onSelect={(selected) => { + if (isSingleValueSelected(selected)) { + setPath(`${pathPrefix}.${optionName}.sort`, selected.meta) + } + }} + /> +
+
+ ) +} + +function DiscountModeOption({ + item, + pathPrefix, +}: { + item: SchemaActionItem + pathPrefix: string +}) { + const optionName = "discount_mode" as const + + const { setPath } = useRuleEngine() + const optionRow = useOptionRow({ item, optionName, pathPrefix }) + + if (!(optionName in item) || optionRow == null) { + return null + } + + return ( + + v.value === item[optionName], + )?.label ?? item[optionName], + value: item[optionName], + } + : undefined + } + onSelect={(selected) => { + if (isSingleValueSelected(selected)) { + setPath(`${pathPrefix}.${optionName}`, selected.value) + } + }} + /> + + ) +} + +function ApplyOnOption({ + item, + pathPrefix, +}: { + item: SchemaActionItem + pathPrefix: string +}) { + const optionName = "apply_on" as const + + const { setPath } = useRuleEngine() + const optionRow = useOptionRow({ item, optionName, pathPrefix }) + + if (!(optionName in item) || optionRow == null) { + return null + } + + return ( + + v.value === item[optionName], + )?.label ?? item[optionName], + value: item[optionName], + } + : undefined + } + onSelect={(selected) => { + if (isSingleValueSelected(selected)) { + setPath(`${pathPrefix}.${optionName}`, selected.value) + } + }} + /> + {/* { + // TODO: this will be removed when we have static values for apply_on + optionRow.optionConfig?.values == null && ( + { + const suggestions = ( + await fetchCoreResourcesSuggestions( + [optionRow.mainResourceId], + `${item.selector}.${inputValue}`, + ) + ) + .filter( + (s) => + s.type === "field" && + s.value.includes(inputValue) && + s.value.endsWith("_cents"), + ) + .map((suggestion) => { + const value = suggestion.value.replace( + `${item.selector}.`, + "", + ) + + return { + value, + label: value, + } + }) + + return suggestions + }} + defaultValue={ + item[optionName] != null + ? { + label: item[optionName], + value: item[optionName], + } + : undefined + } + onSelect={(selected) => { + if (isSingleValueSelected(selected)) { + setPath(`${pathPrefix}.${optionName}`, selected.value) + } + }} + /> + ) + } */} + + ) +} + +function IdentifiersOption({ + item, + pathPrefix, +}: { + item: SchemaActionItem + pathPrefix: string +}) { + const optionName = "identifiers" as const + + const { setPath } = useRuleEngine() + // const [rerenderKey, setRerenderKey] = useState(0) + const optionRow = useOptionRow({ item, optionName, pathPrefix }) + + if (optionRow == null || optionRow.optionConfig?.required !== true) { + return null + } + + const selectedIdentifiers = optionName in item ? item.identifiers : {} + + const allValues = optionRow.optionConfig?.values ?? [] + + return allValues.map(({ label, value }) => { + const resourceType = + value === "order.line_items.sku.id" + ? "skus" + : value === "order.line_items.bundle.id" + ? "bundles" + : value === "order.line_items.sku.sku_lists.id" + ? "sku_lists" + : undefined + if (resourceType == null) { + return null + } + return ( + + Free {label} + + } + key={value} + > + { + if (isMultiValueSelected(selected)) { + if (selected.length > 0) { + setPath(`${pathPrefix}.${optionName}`, { + ...selectedIdentifiers, + [value]: selected + .map((s) => s.value) + .filter((s) => s != null), + }) + } else { + const updatedIdentifiers = { ...selectedIdentifiers } + delete updatedIdentifiers[value] + setPath(`${pathPrefix}.${optionName}`, updatedIdentifiers) + } + } + }} + /> + + ) + }) +} + +const actionPaths = { + "order-rules": [ + "order", + "order.line_items", + "order.line_items.line_item_options", + "order.line_items.sku", + "order.line_items.bundle", + "order.line_items.shipment", + "order.line_items.payment_method", + "order.line_items.adjustment", + "order.line_items.gift_card", + ] as const, + "price-rules": ["price"] as const, +} satisfies Record diff --git a/packages/app-elements/src/ui/forms/RuleEngine/Options/ConditionOptions.tsx b/packages/app-elements/src/ui/forms/RuleEngine/Options/ConditionOptions.tsx new file mode 100644 index 00000000..58adec21 --- /dev/null +++ b/packages/app-elements/src/ui/forms/RuleEngine/Options/ConditionOptions.tsx @@ -0,0 +1,200 @@ +import { useState } from "react" +import { Button } from "#ui/atoms/Button" +import { Icon } from "#ui/atoms/Icon" +import { Dropdown, DropdownDivider, DropdownItem } from "#ui/composite/Dropdown" +import { InputSelect, isSingleValueSelected } from "#ui/forms/InputSelect" +import { useAvailableGroups } from "../Condition/hooks" +import { useRuleEngine } from "../RuleEngineContext" +import type { SchemaConditionItem } from "../utils" +import { AggregationRow, useOptionRow } from "./common" + +export function ConditionOptions({ + item, + pathPrefix, +}: { + item: SchemaConditionItem | null + pathPrefix: string +}) { + if (item == null) { + return null + } + + return ( + <> + + + + + ) +} + +function GroupOption({ + item, + pathPrefix, +}: { + item: SchemaConditionItem + pathPrefix: string +}) { + const optionName = "group" as const + + const { setPath } = useRuleEngine() + const optionRow = useOptionRow({ item, optionName, pathPrefix }) + + const availableGroups = useAvailableGroups() + + if (!(optionName in item) || optionRow == null || item.group === undefined) { + return null + } + + return ( + + ({ + value: group, + label: group, + }))} + defaultValue={ + item.group != null + ? { + value: item.group, + label: item.group, + } + : undefined + } + onSelect={(selected) => { + if (selected == null || isSingleValueSelected(selected)) { + setPath(`${pathPrefix}.group`, selected?.value.toString()) + } + }} + placeholder="Select or create group…" + /> + + ) +} + +function AggregationsOption({ + item, + pathPrefix, +}: { + item: SchemaConditionItem + pathPrefix: string +}) { + const optionName = "aggregations" as const + + const { setPath } = useRuleEngine() + const [rerenderKey, setRerenderKey] = useState(0) + const optionRow = useOptionRow({ item, optionName, pathPrefix }) + + if ( + !(optionName in item) || + optionRow == null || + item[optionName] == null || + item[optionName].length === 0 + ) { + return null + } + + return ( + + {item.aggregations?.map((aggregation, index) => { + const key = index.toString() + return ( +
+ + + + + } + dropdownItems={[ + { + setPath( + `${pathPrefix}.aggregations.${item.aggregations?.length}`, + {}, + ) + }} + />, + { + setPath( + `${pathPrefix}.aggregations.${item.aggregations?.length}`, + aggregation, + ) + }} + />, + , + { + if (item.aggregations?.length === 1) { + setPath(`${pathPrefix}.aggregations`, null) + } else { + setPath(`${pathPrefix}.aggregations.${index}`, null) + } + setRerenderKey((prev) => prev + 1) + }} + />, + ]} + /> +
+ ) + })} +
+ ) +} + +function ScopeOption({ + item, + pathPrefix, +}: { + item: SchemaConditionItem + pathPrefix: string +}) { + const optionName = "scope" as const + + const { setPath } = useRuleEngine() + const optionRow = useOptionRow({ item, optionName, pathPrefix }) + + if (!(optionName in item) || optionRow == null) { + return null + } + + return ( + + v.value === item[optionName], + )?.label ?? item[optionName], + value: item[optionName], + } + : undefined + } + onSelect={(selected) => { + if (isSingleValueSelected(selected)) { + setPath(`${pathPrefix}.${optionName}`, selected.value) + } + }} + /> + + ) +} diff --git a/packages/app-elements/src/ui/forms/RuleEngine/Options/common.tsx b/packages/app-elements/src/ui/forms/RuleEngine/Options/common.tsx new file mode 100644 index 00000000..7cbe1995 --- /dev/null +++ b/packages/app-elements/src/ui/forms/RuleEngine/Options/common.tsx @@ -0,0 +1,222 @@ +import { isEqual } from "lodash-es" +import { useCallback } from "react" +import { Icon } from "#ui/atoms/Icon" +import { Text } from "#ui/atoms/Text" +import { Dropdown, DropdownItem } from "#ui/composite/Dropdown" +import { Input } from "#ui/forms/Input" +import { InputSelect, isSingleValueSelected } from "#ui/forms/InputSelect" +import { OptionRow } from "../layout/OptionRow" +import { + type ManagedActionOption, + type ManagedConditionOption, + OPTION_LABELS, + type OptionConfig, +} from "../optionsConfig" +import { useRuleEngine } from "../RuleEngineContext" +import type { SchemaActionItem, SchemaConditionItem } from "../utils" + +export function useOptionRow({ + item, + optionName, + pathPrefix, +}: { + item: SchemaActionItem | SchemaConditionItem + optionName: ManagedActionOption | ManagedConditionOption + pathPrefix: string +}): { + optionConfig?: OptionConfig + mainResourceId: "price" | "order" + OptionRow: React.FC<{ children: React.ReactNode }> +} | null { + const { setPath, optionsConfig, schemaType } = useRuleEngine() + + const mainResourceId = + schemaType === "order-rules" + ? "order" + : schemaType === "price-rules" + ? "price" + : undefined + + const selector = item != null && "selector" in item ? item.selector : "" + + const optionConfig = + "type" in item + ? optionsConfig.actions[item.type]?.[ + selector as keyof (typeof optionsConfig.actions)[typeof item.type] + ]?.find((opt) => opt.name === optionName) + : optionsConfig.conditions.find((opt) => opt.name === optionName) + + const CustomizedOptionRow = useCallback( + ({ children }: { children: React.ReactNode }) => { + const label = optionConfig?.label ?? OPTION_LABELS[optionName] + const required = optionConfig?.required === true + + if (required) { + return ( + + {label} + + } + > + {children} + + ) + } + + return ( + { + setPath(`${pathPrefix}.${optionName}`, null) + }} + label="Remove" + disabled={optionConfig?.required === true} + />, + ]} + dropdownLabel={ + + } + /> + } + > + {children} + + ) + }, + [pathPrefix, optionName, optionConfig, setPath], + ) + + if ( + (!(optionName in item) && optionConfig == null) || + // (!(optionName in item) && optionConfig?.required === false) || + mainResourceId == null + ) { + return null + } + + return { + optionConfig, + mainResourceId, + OptionRow: CustomizedOptionRow, + } +} + +export function AggregationRow({ + aggregation, + pathPrefix, + optionConfig, +}: { + aggregation: + | NonNullable< + Extract["aggregation"] + > + | NonNullable[number] + pathPrefix: string + optionConfig?: OptionConfig +}) { + const { setPath } = useRuleEngine() + + const defaultValue = optionConfig?.values?.find( + (entry) => + isEqual(aggregation.field, entry.meta?.field) && + isEqual(aggregation.operator, entry.meta?.operator), + ) + + const matchers = [ + { label: "=", value: "eq" }, + { label: ">", value: "gt" }, + { label: "<", value: "lt" }, + { label: ">=", value: "gteq" }, + { label: "<=", value: "lteq" }, + { label: "≠", value: "not_eq" }, + { label: "multiple", value: "multiple" }, + ] + + return ( +
+ ({ + label: entry.label, + meta: entry.meta, + value: JSON.stringify(entry.meta), + })) ?? [] + } + onSelect={(selected) => { + if (isSingleValueSelected(selected)) { + setPath(`${pathPrefix}.field`, selected.meta?.field) + setPath(`${pathPrefix}.operator`, selected.meta?.operator) + } + }} + /> + v.value === aggregation.matcher) + ?.label ?? aggregation.matcher, + value: aggregation.matcher, + } + : undefined + } + initialValues={matchers} + onSelect={(selected) => { + if (isSingleValueSelected(selected)) { + setPath(`${pathPrefix}.matcher`, selected.value) + } + }} + /> + { + const value = parseInt(e.currentTarget.value, 10) + setPath(`${pathPrefix}.value`, value) + }} + defaultValue={aggregation.value} + /> +
+ ) +} diff --git a/packages/app-elements/src/ui/forms/RuleEngine/Options/index.tsx b/packages/app-elements/src/ui/forms/RuleEngine/Options/index.tsx index f04d62fd..2eecf199 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/Options/index.tsx +++ b/packages/app-elements/src/ui/forms/RuleEngine/Options/index.tsx @@ -1,885 +1,2 @@ -import { isEqual } from "lodash-es" -import { useCallback, useState } from "react" -import { Button } from "#ui/atoms/Button" -import { Icon } from "#ui/atoms/Icon" -import { Text } from "#ui/atoms/Text" -import { Dropdown, DropdownDivider, DropdownItem } from "#ui/composite/Dropdown" -import { Input } from "#ui/forms/Input" -import { - InputSelect, - isMultiValueSelected, - isSingleValueSelected, -} from "#ui/forms/InputSelect" -import { useAvailableGroups } from "../Condition/hooks" -import { InputResourceSelector } from "../InputResourceSelector" -import { OptionRow } from "../layout/OptionRow" -import { - type ManagedActionOption, - type ManagedConditionOption, - OPTION_LABELS, - type OptionConfig, -} from "../optionsConfig" -import { useRuleEngine } from "../RuleEngineContext" -import type { SchemaActionItem, SchemaConditionItem } from "../utils" - -export function Options({ - item, - pathPrefix, -}: { - item: SchemaActionItem | SchemaConditionItem | null - pathPrefix: string -}) { - if (item == null) { - return null - } - - return ( - <> - - - - - - - - - - - - - ) -} - -function GroupOption({ - item, - pathPrefix, -}: { - item: SchemaActionItem | SchemaConditionItem - pathPrefix: string -}) { - const optionName = "group" as const - - const { setPath } = useRuleEngine() - const optionRow = useOptionRow({ item, optionName, pathPrefix }) - - const availableGroups = useAvailableGroups() - - if (!(optionName in item) || optionRow == null || item.group === undefined) { - return null - } - - return ( - - ({ - value: group, - label: group, - }))} - defaultValue={ - item.group != null - ? { - value: item.group, - label: item.group, - } - : undefined - } - onSelect={(selected) => { - if (selected == null || isSingleValueSelected(selected)) { - setPath(`${pathPrefix}.group`, selected?.value.toString()) - } - }} - placeholder="Select or create group…" - /> - - ) -} - -function RoundOption({ - item, - pathPrefix, -}: { - item: SchemaActionItem | SchemaConditionItem - pathPrefix: string -}) { - const optionName = "round" as const - - const { setPath } = useRuleEngine() - const optionRow = useOptionRow({ item, optionName, pathPrefix }) - - if (!(optionName in item) || optionRow == null) { - return null - } - - const initialValues = [ - { label: "Yes", value: true }, - { label: "No", value: false }, - ] - - const defaultValue = initialValues.find((v) => v.value === item[optionName]) - - return ( - - { - if (isSingleValueSelected(selected)) { - setPath(`${pathPrefix}.${optionName}`, selected.value) - } - }} - /> - - ) -} - -function QuantityOption({ - item, - pathPrefix, -}: { - item: SchemaActionItem | SchemaConditionItem - pathPrefix: string -}) { - const optionName = "quantity" as const - - const { setPath } = useRuleEngine() - const optionRow = useOptionRow({ item, optionName, pathPrefix }) - - if (!(optionName in item) || optionRow == null) { - return null - } - - const defaultValue = item[optionName] - - return ( - - { - const value = parseInt(e.currentTarget.value, 10) - setPath(`${pathPrefix}.${optionName}`, value) - }} - /> - - ) -} - -function LimitOption({ - item, - pathPrefix, -}: { - item: SchemaActionItem | SchemaConditionItem - pathPrefix: string -}) { - const optionName = "limit" as const - - const { setPath } = useRuleEngine() - const optionRow = useOptionRow({ item, optionName, pathPrefix }) - - if (!(optionName in item) || optionRow == null) { - return null - } - - const defaultValue = optionRow.optionConfig?.values?.find((entry) => - isEqual(item.limit?.sort, entry.meta), - ) - - return ( - -
- { - const value = parseInt(e.currentTarget.value, 10) - setPath(`${pathPrefix}.${optionName}.value`, value) - }} - defaultValue={item.limit?.value} - /> - ({ - label: entry.label, - meta: entry.meta, - value: JSON.stringify(entry.meta), - })) ?? [] - } - onSelect={(selected) => { - if (isSingleValueSelected(selected)) { - setPath(`${pathPrefix}.${optionName}.sort`, selected.meta) - } - }} - /> -
-
- ) -} - -function AggregationOption({ - item, - pathPrefix, -}: { - item: SchemaActionItem | SchemaConditionItem - pathPrefix: string -}) { - const optionName = "aggregation" as const - - const optionRow = useOptionRow({ item, optionName, pathPrefix }) - - if (!(optionName in item) || optionRow == null || item[optionName] == null) { - return null - } - - return ( - - - - ) -} - -function AggregationRow({ - aggregation, - pathPrefix, - optionConfig, -}: { - aggregation: - | NonNullable - | NonNullable[number] - pathPrefix: string - optionConfig?: OptionConfig -}) { - const { setPath } = useRuleEngine() - - const defaultValue = optionConfig?.values?.find( - (entry) => - isEqual(aggregation.field, entry.meta?.field) && - isEqual(aggregation.operator, entry.meta?.operator), - ) - - const matchers = [ - { label: "=", value: "eq" }, - { label: ">", value: "gt" }, - { label: "<", value: "lt" }, - { label: ">=", value: "gteq" }, - { label: "<=", value: "lteq" }, - { label: "≠", value: "not_eq" }, - { label: "multiple", value: "multiple" }, - ] - - return ( -
- ({ - label: entry.label, - meta: entry.meta, - value: JSON.stringify(entry.meta), - })) ?? [] - } - onSelect={(selected) => { - if (isSingleValueSelected(selected)) { - setPath(`${pathPrefix}.field`, selected.meta?.field) - setPath(`${pathPrefix}.operator`, selected.meta?.operator) - } - }} - /> - v.value === aggregation.matcher) - ?.label ?? aggregation.matcher, - value: aggregation.matcher, - } - : undefined - } - initialValues={matchers} - onSelect={(selected) => { - if (isSingleValueSelected(selected)) { - setPath(`${pathPrefix}.matcher`, selected.value) - } - }} - /> - { - const value = parseInt(e.currentTarget.value, 10) - setPath(`${pathPrefix}.value`, value) - }} - defaultValue={aggregation.value} - /> -
- ) -} - -function AggregationsOption({ - item, - pathPrefix, -}: { - item: SchemaActionItem | SchemaConditionItem - pathPrefix: string -}) { - const optionName = "aggregations" as const - - const { setPath } = useRuleEngine() - const [rerenderKey, setRerenderKey] = useState(0) - const optionRow = useOptionRow({ item, optionName, pathPrefix }) - - if ( - !(optionName in item) || - optionRow == null || - item[optionName] == null || - item[optionName].length === 0 - ) { - return null - } - - return ( - - {item.aggregations?.map((aggregation, index) => { - const key = index.toString() - return ( -
- - - - - } - dropdownItems={[ - { - setPath( - `${pathPrefix}.aggregations.${item.aggregations?.length}`, - {}, - ) - }} - />, - { - setPath( - `${pathPrefix}.aggregations.${item.aggregations?.length}`, - aggregation, - ) - }} - />, - , - { - if (item.aggregations?.length === 1) { - setPath(`${pathPrefix}.aggregations`, null) - } else { - setPath(`${pathPrefix}.aggregations.${index}`, null) - } - setRerenderKey((prev) => prev + 1) - }} - />, - ]} - /> -
- ) - })} -
- ) -} - -function BundleOption({ - item, - pathPrefix, -}: { - item: SchemaActionItem | SchemaConditionItem - pathPrefix: string -}) { - const optionName = "bundle" as const - - const { setPath } = useRuleEngine() - const optionRow = useOptionRow({ item, optionName, pathPrefix }) - - if (!(optionName in item) || optionRow == null) { - return null - } - - const defaultValue = optionRow.optionConfig?.values?.find((entry) => - isEqual(item.bundle?.sort, entry.meta), - ) - - const bundleTypes = [ - { label: "Balanced", value: "balanced" }, - { label: "Every", value: "every" }, - ] - - return ( - -
- v.value === item.bundle?.type) ?? { - label: item.bundle.type, - value: item.bundle.type, - }) - } - onSelect={(selected) => { - if (isSingleValueSelected(selected)) { - setPath(`${pathPrefix}.${optionName}.type`, selected.value) - - if (selected.value === "balanced") { - setPath(`${pathPrefix}.${optionName}.value`, null) - } - } - }} - /> - {item.bundle?.type === "every" && ( - { - const value = parseInt(e.currentTarget.value, 10) - setPath(`${pathPrefix}.${optionName}.value`, value) - }} - defaultValue={item.bundle?.value} - /> - )} - ({ - label: entry.label, - meta: entry.meta, - value: JSON.stringify(entry.meta), - })) ?? [] - } - onSelect={(selected) => { - if (isSingleValueSelected(selected)) { - setPath(`${pathPrefix}.${optionName}.sort`, selected.meta) - } - }} - /> -
-
- ) -} - -function DiscountModeOption({ - item, - pathPrefix, -}: { - item: SchemaActionItem | SchemaConditionItem - pathPrefix: string -}) { - const optionName = "discount_mode" as const - - const { setPath } = useRuleEngine() - const optionRow = useOptionRow({ item, optionName, pathPrefix }) - - if (!(optionName in item) || optionRow == null) { - return null - } - - return ( - - v.value === item[optionName], - )?.label ?? item[optionName], - value: item[optionName], - } - : undefined - } - onSelect={(selected) => { - if (isSingleValueSelected(selected)) { - setPath(`${pathPrefix}.${optionName}`, selected.value) - } - }} - /> - - ) -} - -function ScopeOption({ - item, - pathPrefix, -}: { - item: SchemaActionItem | SchemaConditionItem - pathPrefix: string -}) { - const optionName = "scope" as const - - const { setPath } = useRuleEngine() - const optionRow = useOptionRow({ item, optionName, pathPrefix }) - - if (!(optionName in item) || optionRow == null) { - return null - } - - return ( - - v.value === item[optionName], - )?.label ?? item[optionName], - value: item[optionName], - } - : undefined - } - onSelect={(selected) => { - if (isSingleValueSelected(selected)) { - setPath(`${pathPrefix}.${optionName}`, selected.value) - } - }} - /> - - ) -} - -function ApplyOnOption({ - item, - pathPrefix, -}: { - item: SchemaActionItem | SchemaConditionItem - pathPrefix: string -}) { - const optionName = "apply_on" as const - - const { setPath } = useRuleEngine() - const optionRow = useOptionRow({ item, optionName, pathPrefix }) - - if (!(optionName in item) || optionRow == null) { - return null - } - - return ( - - v.value === item[optionName], - )?.label ?? item[optionName], - value: item[optionName], - } - : undefined - } - onSelect={(selected) => { - if (isSingleValueSelected(selected)) { - setPath(`${pathPrefix}.${optionName}`, selected.value) - } - }} - /> - {/* { - // TODO: this will be removed when we have static values for apply_on - optionRow.optionConfig?.values == null && ( - { - const suggestions = ( - await fetchCoreResourcesSuggestions( - [optionRow.mainResourceId], - `${item.selector}.${inputValue}`, - ) - ) - .filter( - (s) => - s.type === "field" && - s.value.includes(inputValue) && - s.value.endsWith("_cents"), - ) - .map((suggestion) => { - const value = suggestion.value.replace( - `${item.selector}.`, - "", - ) - - return { - value, - label: value, - } - }) - - return suggestions - }} - defaultValue={ - item[optionName] != null - ? { - label: item[optionName], - value: item[optionName], - } - : undefined - } - onSelect={(selected) => { - if (isSingleValueSelected(selected)) { - setPath(`${pathPrefix}.${optionName}`, selected.value) - } - }} - /> - ) - } */} - - ) -} - -function IdentifiersOption({ - item, - pathPrefix, -}: { - item: SchemaActionItem | SchemaConditionItem - pathPrefix: string -}) { - const optionName = "identifiers" as const - - const { setPath } = useRuleEngine() - // const [rerenderKey, setRerenderKey] = useState(0) - const optionRow = useOptionRow({ item, optionName, pathPrefix }) - - if (optionRow == null || optionRow.optionConfig?.required !== true) { - return null - } - - const selectedIdentifiers = optionName in item ? item.identifiers : {} - - const allValues = optionRow.optionConfig?.values ?? [] - - return allValues.map(({ label, value }) => { - const resourceType = - value === "order.line_items.sku.id" - ? "skus" - : value === "order.line_items.bundle.id" - ? "bundles" - : value === "order.line_items.sku.sku_lists.id" - ? "sku_lists" - : undefined - if (resourceType == null) { - return null - } - return ( - - Free {label} - - } - key={value} - > - { - console.log("selected", selected) - if (isMultiValueSelected(selected)) { - if (selected.length > 0) { - setPath(`${pathPrefix}.${optionName}`, { - ...selectedIdentifiers, - [value]: selected - .map((s) => s.value) - .filter((s) => s != null), - }) - } else { - const updatedIdentifiers = { ...selectedIdentifiers } - delete updatedIdentifiers[value] - setPath(`${pathPrefix}.${optionName}`, updatedIdentifiers) - } - } - }} - /> - - ) - }) -} - -function useOptionRow({ - item, - optionName, - pathPrefix, -}: { - item: SchemaActionItem | SchemaConditionItem - optionName: ManagedActionOption | ManagedConditionOption - pathPrefix: string -}): { - optionConfig?: OptionConfig - mainResourceId: "price" | "order" - OptionRow: React.FC<{ children: React.ReactNode }> -} | null { - const { setPath, optionsConfig, schemaType } = useRuleEngine() - - const mainResourceId = - schemaType === "order-rules" - ? "order" - : schemaType === "price-rules" - ? "price" - : undefined - - const optionConfig = - "type" in item && item.selector != null - ? optionsConfig.actions[item.type]?.[ - item.selector as keyof (typeof optionsConfig.actions)[typeof item.type] - ]?.find((opt) => opt.name === optionName) - : optionsConfig.conditions.find((opt) => opt.name === optionName) - - const CustomizedOptionRow = useCallback( - ({ children }: { children: React.ReactNode }) => { - const label = optionConfig?.label ?? OPTION_LABELS[optionName] - const required = optionConfig?.required === true - - if (required) { - return ( - - {label} - - } - > - {children} - - ) - } - - return ( - { - setPath(`${pathPrefix}.${optionName}`, null) - }} - label="Remove" - disabled={optionConfig?.required === true} - />, - ]} - dropdownLabel={ - - } - /> - } - > - {children} - - ) - }, - [pathPrefix, optionName, optionConfig, setPath], - ) - - if (!(optionName in item) || mainResourceId == null) { - return null - } - - return { - optionConfig, - mainResourceId, - OptionRow: CustomizedOptionRow, - } -} +export { ActionOptions } from "./ActionOptions" +export { ConditionOptions } from "./ConditionOptions" diff --git a/packages/app-elements/src/ui/forms/RuleEngine/RuleEngineContext.tsx b/packages/app-elements/src/ui/forms/RuleEngine/RuleEngineContext.tsx index 858c3e08..d84e4314 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/RuleEngineContext.tsx +++ b/packages/app-elements/src/ui/forms/RuleEngine/RuleEngineContext.tsx @@ -74,24 +74,24 @@ const RuleEngineContext = createContext( // } // } +// // Ensure that if we are setting a field inside an action, the action has a groups array +// if (/actions\.\d\.[\w_]+$/.test(action.path)) { +// const parentPath = action.path.replace(/\.[\w_]+$/, "") +// const parentValue = get(newValue, parentPath) as Record< +// string, +// unknown +// > | null + +// if (parentValue?.groups == null) { +// set(newValue, `${parentPath}.groups`, []) +// } +// } + function ruleEngineReducer(state: State, action: Action): State { switch (action.type) { case "SET_PATH": { const newValue = cloneDeep(state.value) - // Ensure that if we are setting a field inside an action, the action has a groups array - if (/actions\.\d\.[\w_]+$/.test(action.path)) { - const parentPath = action.path.replace(/\.[\w_]+$/, "") - const parentValue = get(newValue, parentPath) as Record< - string, - unknown - > | null - - if (parentValue?.groups == null) { - set(newValue, `${parentPath}.groups`, []) - } - } - if (action.value === null && action.allowNullValue === false) { if (/\.\d+$/.test(action.path)) { // If the path ends with a number, we assume it's an array index diff --git a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.json b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.json index a9c64bcd..9855c1e5 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.json +++ b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.json @@ -81,7 +81,7 @@ "apply_on": { "$ref": "#/$defs/apply_on" }, "limit": { "$ref": "#/$defs/limit" } }, - "required": ["type", "selector", "value"], + "required": ["type", "selector", "value", "groups"], "additionalProperties": false }, { @@ -133,7 +133,7 @@ "limit": { "$ref": "#/$defs/limit" }, "quantity": { "$ref": "#/$defs/quantity" } }, - "required": ["type", "selector", "value"], + "required": ["type", "selector", "value", "groups"], "additionalProperties": false }, { @@ -193,7 +193,7 @@ "examples": ["distributed"] } }, - "required": ["type", "selector", "value"], + "required": ["type", "selector", "value", "groups"], "additionalProperties": false }, { @@ -255,7 +255,7 @@ "examples": ["distributed"] } }, - "required": ["type", "selector", "value"], + "required": ["type", "selector", "value", "groups"], "additionalProperties": false }, { @@ -303,7 +303,7 @@ "apply_on": { "$ref": "#/$defs/apply_on" }, "limit": { "$ref": "#/$defs/limit" } }, - "required": ["type", "selector", "value"], + "required": ["type", "selector", "value", "groups"], "additionalProperties": false }, { @@ -353,7 +353,7 @@ "limit": { "$ref": "#/$defs/limit" }, "quantity": { "$ref": "#/$defs/quantity" } }, - "required": ["type", "selector", "value"], + "required": ["type", "selector", "value", "groups"], "additionalProperties": false }, { @@ -390,7 +390,7 @@ "required": ["x", "y"] } }, - "required": ["type", "value"], + "required": ["type", "value", "groups", "selector"], "additionalProperties": false }, { @@ -431,7 +431,7 @@ "required": ["x", "y", "attribute"] } }, - "required": ["type", "value", "selector"], + "required": ["type", "value", "selector", "groups"], "additionalProperties": false }, { @@ -443,10 +443,6 @@ "description": "The type of action you want to apply.", "examples": ["free_gift"] }, - "selector": { "$ref": "#/$defs/selector" }, - "identifier": { "$ref": "#/$defs/identifier" }, - "groups": { "$ref": "#/$defs/groups" }, - "aggregation": { "$ref": "#/$defs/aggregation" }, "identifiers": { "type": "object", "description": "Object whose keys are allowed selector paths (order.line_items.sku.id, order.line_items.bundle.id, order.line_items.sku.sku_lists.id). Each value is an array of ids to match. Line items matching any (selector, id) are eligible for the free gift.", diff --git a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.schema.ts b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.schema.ts index 8a4c07c8..c9d1bae3 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.schema.ts +++ b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/order_rules.schema.ts @@ -210,7 +210,7 @@ export interface RulesForOrderContext { type: "percentage" selector: Selector identifier?: Identifier - groups?: Groups + groups: Groups aggregation?: Aggregation /** * Percentage to be discounted, @@ -245,7 +245,7 @@ export interface RulesForOrderContext { type: "percentage" selector: Selector identifier?: Identifier - groups?: Groups + groups: Groups aggregation?: Aggregation /** * Percentage to be discounted, @@ -283,7 +283,7 @@ export interface RulesForOrderContext { type: "fixed_amount" selector: Selector identifier?: Identifier - groups?: Groups + groups: Groups aggregation?: Aggregation /** * The discount fixed amount to be applied. @@ -324,7 +324,7 @@ export interface RulesForOrderContext { type: "fixed_amount" selector: Selector identifier?: Identifier - groups?: Groups + groups: Groups aggregation?: Aggregation /** * The discount fixed amount to be applied. @@ -361,7 +361,7 @@ export interface RulesForOrderContext { type: "fixed_price" selector: Selector identifier?: Identifier - groups?: Groups + groups: Groups aggregation?: Aggregation /** * The price fixed amount to be applied. @@ -394,7 +394,7 @@ export interface RulesForOrderContext { type: "fixed_price" selector: Selector identifier?: Identifier - groups?: Groups + groups: Groups aggregation?: Aggregation /** * The price fixed amount to be applied. @@ -409,9 +409,9 @@ export interface RulesForOrderContext { * The type of action you want to apply. */ type: "buy_x_pay_y" - selector?: Selector + selector: Selector identifier?: Identifier - groups?: Groups + groups: Groups aggregation?: Aggregation value: { /** @@ -436,7 +436,7 @@ export interface RulesForOrderContext { type: "every_x_discount_y" selector: Selector identifier?: Identifier - groups?: Groups + groups: Groups aggregation?: Aggregation value: { /** @@ -459,10 +459,6 @@ export interface RulesForOrderContext { * The type of action you want to apply. */ type: "free_gift" - selector?: Selector - identifier?: Identifier - groups?: Groups - aggregation?: Aggregation /** * Object whose keys are allowed selector paths (order.line_items.sku.id, order.line_items.bundle.id, order.line_items.sku.sku_lists.id). Each value is an array of ids to match. Line items matching any (selector, id) are eligible for the free gift. */ diff --git a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.json b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.json index 1f77faa4..2bf60d8f 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.json +++ b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.json @@ -81,7 +81,7 @@ "apply_on": { "$ref": "#/$defs/apply_on" }, "limit": { "$ref": "#/$defs/limit" } }, - "required": ["type", "selector", "value"], + "required": ["type", "selector", "value", "groups"], "additionalProperties": false }, { @@ -133,7 +133,7 @@ "limit": { "$ref": "#/$defs/limit" }, "quantity": { "$ref": "#/$defs/quantity" } }, - "required": ["type", "selector", "value"], + "required": ["type", "selector", "value", "groups"], "additionalProperties": false }, { @@ -193,7 +193,7 @@ "examples": ["distributed"] } }, - "required": ["type", "selector", "value"], + "required": ["type", "selector", "value", "groups"], "additionalProperties": false }, { @@ -255,7 +255,7 @@ "examples": ["distributed"] } }, - "required": ["type", "selector", "value"], + "required": ["type", "selector", "value", "groups"], "additionalProperties": false }, { @@ -303,7 +303,7 @@ "apply_on": { "$ref": "#/$defs/apply_on" }, "limit": { "$ref": "#/$defs/limit" } }, - "required": ["type", "selector", "value"], + "required": ["type", "selector", "value", "groups"], "additionalProperties": false }, { @@ -353,7 +353,7 @@ "limit": { "$ref": "#/$defs/limit" }, "quantity": { "$ref": "#/$defs/quantity" } }, - "required": ["type", "selector", "value"], + "required": ["type", "selector", "value", "groups"], "additionalProperties": false } ] diff --git a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.schema.ts b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.schema.ts index 381bc779..271cba97 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.schema.ts +++ b/packages/app-elements/src/ui/forms/RuleEngine/json_schema/price_rules.schema.ts @@ -210,7 +210,7 @@ export interface RulesForPriceContext { type: "percentage" selector: Selector identifier?: Identifier - groups?: Groups + groups: Groups aggregation?: Aggregation /** * Percentage to be discounted, @@ -245,7 +245,7 @@ export interface RulesForPriceContext { type: "percentage" selector: Selector identifier?: Identifier - groups?: Groups + groups: Groups aggregation?: Aggregation /** * Percentage to be discounted, @@ -283,7 +283,7 @@ export interface RulesForPriceContext { type: "fixed_amount" selector: Selector identifier?: Identifier - groups?: Groups + groups: Groups aggregation?: Aggregation /** * The discount fixed amount to be applied. @@ -324,7 +324,7 @@ export interface RulesForPriceContext { type: "fixed_amount" selector: Selector identifier?: Identifier - groups?: Groups + groups: Groups aggregation?: Aggregation /** * The discount fixed amount to be applied. @@ -361,7 +361,7 @@ export interface RulesForPriceContext { type: "fixed_price" selector: Selector identifier?: Identifier - groups?: Groups + groups: Groups aggregation?: Aggregation /** * The price fixed amount to be applied. @@ -394,7 +394,7 @@ export interface RulesForPriceContext { type: "fixed_price" selector: Selector identifier?: Identifier - groups?: Groups + groups: Groups aggregation?: Aggregation /** * The price fixed amount to be applied. diff --git a/packages/app-elements/src/ui/forms/RuleEngine/layout/OptionRow.tsx b/packages/app-elements/src/ui/forms/RuleEngine/layout/OptionRow.tsx index d2c1e079..73c5a712 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/layout/OptionRow.tsx +++ b/packages/app-elements/src/ui/forms/RuleEngine/layout/OptionRow.tsx @@ -8,9 +8,7 @@ export const OptionRow: React.FC<{ }> = ({ children, label, className }) => { return (
-
- {label} -
+
{label}
{children}
) diff --git a/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.test.ts b/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.test.ts index 495b2f8a..57756b3e 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.test.ts +++ b/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.test.ts @@ -14,29 +14,431 @@ describe("parseOptionsFromSchema", () => { { "actions": { "buy_x_pay_y": { - "order": [], - "order.line_items": [], - "order.line_items.adjustment": [], - "order.line_items.bundle": [], - "order.line_items.gift_card": [], - "order.line_items.line_item_options": [], - "order.line_items.payment_method": [], - "order.line_items.shipment": [], - "order.line_items.sku": [], + "": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + ], + "order": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.adjustment": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.bundle": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.gift_card": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.line_item_options": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.payment_method": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.shipment": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.sku": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], }, "every_x_discount_y": { - "order": [], - "order.line_items": [], - "order.line_items.adjustment": [], - "order.line_items.bundle": [], - "order.line_items.gift_card": [], - "order.line_items.line_item_options": [], - "order.line_items.payment_method": [], - "order.line_items.shipment": [], - "order.line_items.sku": [], + "": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + ], + "order": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.adjustment": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.bundle": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.gift_card": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.line_item_options": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.payment_method": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.shipment": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.sku": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], }, "fixed_amount": { + "": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "Optional quantity to override the resource quantity. If specified, the action will apply to the minimum of specified quantity and resource quantity.", + "label": "Quantity", + "mutuallyExclusiveWith": [], + "name": "quantity", + "required": false, + "valueType": "integer", + "values": undefined, + }, + ], "order": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, { "description": "Creates bundles based on the groups provided.", "label": "Bundle", @@ -140,6 +542,24 @@ describe("parseOptionsFromSchema", () => { }, ], "order.line_items": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, { "description": "Creates bundles based on the groups provided.", "label": "Bundle", @@ -242,16 +662,187 @@ describe("parseOptionsFromSchema", () => { "values": undefined, }, ], - "order.line_items.adjustment": [], - "order.line_items.bundle": [], - "order.line_items.gift_card": [], - "order.line_items.line_item_options": [], - "order.line_items.payment_method": [], - "order.line_items.shipment": [], - "order.line_items.sku": [], + "order.line_items.adjustment": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.bundle": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.gift_card": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.line_item_options": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.payment_method": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.shipment": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.sku": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], }, "fixed_price": { + "": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "Optional quantity to override the resource quantity. If specified, the action will apply to the minimum of specified quantity and resource quantity.", + "label": "Quantity", + "mutuallyExclusiveWith": [], + "name": "quantity", + "required": false, + "valueType": "integer", + "values": undefined, + }, + ], "order": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, { "description": "Creates bundles based on the groups provided.", "label": "Bundle", @@ -337,6 +928,24 @@ describe("parseOptionsFromSchema", () => { }, ], "order.line_items": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, { "description": "Creates bundles based on the groups provided.", "label": "Bundle", @@ -366,71 +975,204 @@ describe("parseOptionsFromSchema", () => { ], }, { - "description": "If provided, applies the action to a specific attribute instead of the default one.", - "label": "Apply on", + "description": "If provided, applies the action to a specific attribute instead of the default one.", + "label": "Apply on", + "mutuallyExclusiveWith": [], + "name": "apply_on", + "required": false, + "valueType": "string", + "values": [ + { + "label": "Unit amount", + "value": "unit_amount_cents", + }, + { + "label": "Compare at amount", + "value": "compare_at_amount_cents", + }, + ], + }, + { + "description": "Restriction on how many resources will be affected by the action.", + "label": "Limit", + "mutuallyExclusiveWith": [ + "bundle", + ], + "name": "limit", + "required": false, + "valueType": "object", + "values": [ + { + "label": "Most expensive", + "meta": { + "attribute": "unit_amount_cents", + "direction": "desc", + }, + "value": "most-expensive", + }, + { + "label": "Less expensive", + "meta": { + "attribute": "unit_amount_cents", + "direction": "asc", + }, + "value": "less-expensive", + }, + ], + }, + { + "description": "Optional quantity to override the resource quantity. If specified, the action will apply to the minimum of specified quantity and resource quantity.", + "label": "Quantity", + "mutuallyExclusiveWith": [], + "name": "quantity", + "required": false, + "valueType": "integer", + "values": undefined, + }, + ], + "order.line_items.adjustment": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.bundle": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.gift_card": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.line_item_options": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.payment_method": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.shipment": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.sku": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", "mutuallyExclusiveWith": [], - "name": "apply_on", - "required": false, + "name": "selector", + "required": true, "valueType": "string", - "values": [ - { - "label": "Unit amount", - "value": "unit_amount_cents", - }, - { - "label": "Compare at amount", - "value": "compare_at_amount_cents", - }, - ], - }, - { - "description": "Restriction on how many resources will be affected by the action.", - "label": "Limit", - "mutuallyExclusiveWith": [ - "bundle", - ], - "name": "limit", - "required": false, - "valueType": "object", - "values": [ - { - "label": "Most expensive", - "meta": { - "attribute": "unit_amount_cents", - "direction": "desc", - }, - "value": "most-expensive", - }, - { - "label": "Less expensive", - "meta": { - "attribute": "unit_amount_cents", - "direction": "asc", - }, - "value": "less-expensive", - }, - ], + "values": undefined, }, { - "description": "Optional quantity to override the resource quantity. If specified, the action will apply to the minimum of specified quantity and resource quantity.", - "label": "Quantity", + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", "mutuallyExclusiveWith": [], - "name": "quantity", - "required": false, - "valueType": "integer", + "name": "groups", + "required": true, + "valueType": "array", "values": undefined, }, ], - "order.line_items.adjustment": [], - "order.line_items.bundle": [], - "order.line_items.gift_card": [], - "order.line_items.line_item_options": [], - "order.line_items.payment_method": [], - "order.line_items.shipment": [], - "order.line_items.sku": [], }, "free_gift": { - "order": [ + "": [ { "description": "Object whose keys are allowed selector paths (order.line_items.sku.id, order.line_items.bundle.id, order.line_items.sku.sku_lists.id). Each value is an array of ids to match. Line items matching any (selector, id) are eligible for the free gift.", "label": "Identifiers", @@ -463,7 +1205,7 @@ describe("parseOptionsFromSchema", () => { "values": undefined, }, ], - "order.line_items": [ + "order": [ { "description": "Object whose keys are allowed selector paths (order.line_items.sku.id, order.line_items.bundle.id, order.line_items.sku.sku_lists.id). Each value is an array of ids to match. Line items matching any (selector, id) are eligible for the free gift.", "label": "Identifiers", @@ -496,6 +1238,17 @@ describe("parseOptionsFromSchema", () => { "values": undefined, }, ], + "order.line_items": [ + { + "description": "Total number of units to discount across all matching line items. Distributed in priority order (keys, then ids, then line item order). Required.", + "label": "Quantity", + "mutuallyExclusiveWith": [], + "name": "quantity", + "required": true, + "valueType": "integer", + "values": undefined, + }, + ], "order.line_items.adjustment": [], "order.line_items.bundle": [], "order.line_items.gift_card": [], @@ -505,7 +1258,45 @@ describe("parseOptionsFromSchema", () => { "order.line_items.sku": [], }, "percentage": { + "": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "Optional quantity to override the resource quantity. If specified, the action will apply to the minimum of specified quantity and resource quantity.", + "label": "Quantity", + "mutuallyExclusiveWith": [], + "name": "quantity", + "required": false, + "valueType": "integer", + "values": undefined, + }, + ], "order": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, { "description": "If true, rounds the discount, only available on percentage actions.", "label": "Round", @@ -600,6 +1391,24 @@ describe("parseOptionsFromSchema", () => { }, ], "order.line_items": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, { "description": "If true, rounds the discount, only available on percentage actions.", "label": "Round", @@ -693,13 +1502,146 @@ describe("parseOptionsFromSchema", () => { "values": undefined, }, ], - "order.line_items.adjustment": [], - "order.line_items.bundle": [], - "order.line_items.gift_card": [], - "order.line_items.line_item_options": [], - "order.line_items.payment_method": [], - "order.line_items.shipment": [], - "order.line_items.sku": [], + "order.line_items.adjustment": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.bundle": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.gift_card": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.line_item_options": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.payment_method": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.shipment": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], + "order.line_items.sku": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, + ], }, }, "conditions": [ @@ -771,7 +1713,36 @@ describe("parseOptionsFromSchema", () => { { "actions": { "fixed_amount": { + "": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + ], "price": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, { "description": "If provided, applies the action to a specific attribute instead of the default one.", "label": "Apply on", @@ -793,7 +1764,36 @@ describe("parseOptionsFromSchema", () => { ], }, "fixed_price": { + "": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + ], "price": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, { "description": "If provided, applies the action to a specific attribute instead of the default one.", "label": "Apply on", @@ -815,7 +1815,36 @@ describe("parseOptionsFromSchema", () => { ], }, "percentage": { + "": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + ], "price": [ + { + "description": "The resource on which to apply the action (expressed in dot notation). Can be an attribute if you set also the identifier key.", + "label": "Apply to", + "mutuallyExclusiveWith": [], + "name": "selector", + "required": true, + "valueType": "string", + "values": undefined, + }, + { + "description": "The groups on which to apply the action (must be one or more among the ones defined when grouping the matches of the related conditions).", + "label": "Groups", + "mutuallyExclusiveWith": [], + "name": "groups", + "required": true, + "valueType": "array", + "values": undefined, + }, { "description": "If provided, applies the action to a specific attribute instead of the default one.", "label": "Apply on", @@ -1021,6 +2050,7 @@ describe("useAvailableOptions", () => { const item = { type: "percentage" as const, selector: "order", + groups: [], value: 0.1, limit: { value: 1, diff --git a/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.ts b/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.ts index ab62a104..fb7fa8cb 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.ts +++ b/packages/app-elements/src/ui/forms/RuleEngine/optionsConfig.ts @@ -38,7 +38,18 @@ export interface OptionsConfig { const configuration = { actions: { "order-rules": { + "": { + selector: true, + quantity: true, + identifiers: [ + { label: "SKU", value: "order.line_items.sku.id" }, + { label: "Bundle", value: "order.line_items.bundle.id" }, + { label: "SKU list", value: "order.line_items.sku.sku_lists.id" }, + ], + }, order: { + selector: true, + groups: true, round: true, quantity: true, discount_mode: true, @@ -89,14 +100,11 @@ const configuration = { ], }, "order.line_items": { + selector: true, + groups: true, round: true, quantity: true, discount_mode: true, - identifiers: [ - { label: "SKU", value: "order.line_items.sku.id" }, - { label: "Bundle", value: "order.line_items.bundle.id" }, - { label: "SKU list", value: "order.line_items.sku.sku_lists.id" }, - ], apply_on: [ { label: "Unit amount", value: "unit_amount_cents" }, { label: "Compare at amount", value: "compare_at_amount_cents" }, @@ -138,16 +146,42 @@ const configuration = { }, ], }, - "order.line_items.line_item_options": {}, - "order.line_items.sku": {}, - "order.line_items.bundle": {}, - "order.line_items.shipment": {}, - "order.line_items.payment_method": {}, - "order.line_items.adjustment": {}, - "order.line_items.gift_card": {}, + "order.line_items.line_item_options": { + selector: true, + groups: true, + }, + "order.line_items.sku": { + selector: true, + groups: true, + }, + "order.line_items.bundle": { + selector: true, + groups: true, + }, + "order.line_items.shipment": { + selector: true, + groups: true, + }, + "order.line_items.payment_method": { + selector: true, + groups: true, + }, + "order.line_items.adjustment": { + selector: true, + groups: true, + }, + "order.line_items.gift_card": { + selector: true, + groups: true, + }, } as const, "price-rules": { + "": { + selector: true, + }, price: { + selector: true, + groups: true, apply_on: [ { label: "Amount", value: "amount_cents" }, { label: "Compare at amount", value: "compare_at_amount_cents" }, @@ -223,10 +257,11 @@ type OptionValue = | { label: string value: string - meta?: unknown + meta?: Record }[] type OrderApplyTo = + | "" | "order.line_items.adjustment" | "order.line_items.gift_card" | "order.line_items.shipment" @@ -237,12 +272,14 @@ type OrderApplyTo = | "order.line_items" | "order" -type PriceApplyTo = "price" +type PriceApplyTo = "" | "price" /** * Options that we want to manage dynamically */ const MANAGED_ACTION_OPTIONS = [ + "selector", + "groups", "apply_on", "round", "limit", @@ -265,6 +302,8 @@ export const OPTION_LABELS: Record< ManagedActionOption | ManagedConditionOption, string > = { + selector: "Apply to", + groups: "Groups", apply_on: "Apply on", round: "Round", limit: "Limit", @@ -338,25 +377,21 @@ function parseActionOptions( > for (const applyTo of applyToKeys) { - const configForApplyTo = - configForSchemaType[applyTo as keyof typeof configForSchemaType] + const configForApplyTo = configForSchemaType[ + applyTo as keyof typeof configForSchemaType + ] as Record if (configForApplyTo == null) continue // Filter and enhance options based on configuration actionsConfig[actionType][applyTo] = baseOptions .filter((option) => { - return ( - configForApplyTo[ - option.name as ManagedActionOption | ManagedConditionOption - ] != null - ) + return configForApplyTo[option.name] != null }) .map((option) => { - const optionValue = - configForApplyTo[ - option.name as ManagedActionOption | ManagedConditionOption - ] + const optionValue = configForApplyTo[option.name] as + | OptionValue + | undefined return { ...option, // Configuration values override schema-derived values @@ -639,13 +674,17 @@ export function useAvailableOptions( : isPresent }) - // Determine available and disabled options + // Separate required options + const requiredOptions: OptionConfig[] = optionsConfig.filter( + (opt) => opt.required === true && !currentOptions.includes(opt.name), + ) + + // Only show non-required, not-set, not-conflicting options as available const available: OptionConfig[] = [] const disabled: OptionConfig[] = [] for (const option of optionsConfig) { - // Skip if already set - if (currentOptions.includes(option.name)) { + if (currentOptions.includes(option.name) || option.required) { continue } @@ -661,9 +700,6 @@ export function useAvailableOptions( } } - // Get required options (including those not currently set) - const requiredOptions = optionsConfig.filter((opt) => opt.required === true) - return { available, disabled, diff --git a/packages/docs/public/mockServiceWorker.js b/packages/docs/public/mockServiceWorker.js index 77b55e17..cc46714e 100644 --- a/packages/docs/public/mockServiceWorker.js +++ b/packages/docs/public/mockServiceWorker.js @@ -2,26 +2,26 @@ /* tslint:disable */ /** - * Mock Service Worker (2.1.7). + * Mock Service Worker. * @see https://github.com/mswjs/msw * - Please do NOT modify this file. - * - Please do NOT serve this file on production. */ -const INTEGRITY_CHECKSUM = "223d191a56023cd36aa88c802961b911" +const PACKAGE_VERSION = "2.12.8" +const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82" const IS_MOCKED_RESPONSE = Symbol("isMockedResponse") const activeClientIds = new Set() -self.addEventListener("install", () => { +addEventListener("install", () => { self.skipWaiting() }) -self.addEventListener("activate", (event) => { +addEventListener("activate", (event) => { event.waitUntil(self.clients.claim()) }) -self.addEventListener("message", async (event) => { - const clientId = event.source.id +addEventListener("message", async (event) => { + const clientId = Reflect.get(event.source || {}, "id") if (!clientId || !self.clients) { return @@ -48,7 +48,10 @@ self.addEventListener("message", async (event) => { case "INTEGRITY_CHECK_REQUEST": { sendToClient(client, { type: "INTEGRITY_CHECK_RESPONSE", - payload: INTEGRITY_CHECKSUM, + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, }) break } @@ -58,16 +61,16 @@ self.addEventListener("message", async (event) => { sendToClient(client, { type: "MOCKING_ENABLED", - payload: true, + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, }) break } - case "MOCK_DEACTIVATE": { - activeClientIds.delete(clientId) - break - } - case "CLIENT_CLOSED": { activeClientIds.delete(clientId) @@ -85,72 +88,96 @@ self.addEventListener("message", async (event) => { } }) -self.addEventListener("fetch", (event) => { - const { request } = event +addEventListener("fetch", (event) => { + const requestInterceptedAt = Date.now() - // Bypass navigation requests. - if (request.mode === "navigate") { + if (event.request.mode === "navigate") { return } - // Opening the DevTools triggers the "only-if-cached" request - // that cannot be handled by the worker. Bypass such requests. - if (request.cache === "only-if-cached" && request.mode !== "same-origin") { + if ( + event.request.cache === "only-if-cached" && + event.request.mode !== "same-origin" + ) { return } // Bypass all requests when there are no active clients. // Prevents the self-unregistered worked from handling requests - // after it's been deleted (still remains active until the next reload). + // after it's been terminated (still remains active until the next reload). if (activeClientIds.size === 0) { return } - // Generate unique request ID. const requestId = crypto.randomUUID() - event.respondWith(handleRequest(event, requestId)) + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) }) -async function handleRequest(event, requestId) { +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { const client = await resolveMainClient(event) - const response = await getResponse(event, client, requestId) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) // Send back the response clone for the "response:*" life-cycle events. // Ensure MSW is active and ready to handle the message, otherwise // this message will pend indefinitely. if (client && activeClientIds.has(client.id)) { - ;(async () => { - const responseClone = response.clone() - - sendToClient( - client, - { - type: "RESPONSE", - payload: { - requestId, - isMockedResponse: IS_MOCKED_RESPONSE in response, + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: "RESPONSE", + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { type: responseClone.type, status: responseClone.status, statusText: responseClone.statusText, - body: responseClone.body, headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, }, }, - [responseClone.body], - ) - })() + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) } return response } -// Resolve the main client for the given event. -// Client that issues a request doesn't necessarily equal the client -// that registered the worker. It's with the latter the worker should -// communicate with during the response resolving phase. +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ async function resolveMainClient(event) { const client = await self.clients.get(event.clientId) + if (activeClientIds.has(event.clientId)) { + return client + } + if (client?.frameType === "top-level") { return client } @@ -171,20 +198,39 @@ async function resolveMainClient(event) { }) } -async function getResponse(event, client, requestId) { - const { request } = event - +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { // Clone the request because it might've been already used // (i.e. its body has been read and sent to the client). - const requestClone = request.clone() + const requestClone = event.request.clone() function passthrough() { - const headers = Object.fromEntries(requestClone.headers.entries()) + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get("accept") + if (acceptHeader) { + const values = acceptHeader.split(",").map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== "msw/passthrough", + ) - // Remove internal MSW request header so the passthrough request - // complies with any potential CORS preflight checks on the server. - // Some servers forbid unknown request headers. - delete headers["x-msw-intention"] + if (filteredValues.length > 0) { + headers.set("accept", filteredValues.join(", ")) + } else { + headers.delete("accept") + } + } return fetch(requestClone, { headers }) } @@ -202,37 +248,19 @@ async function getResponse(event, client, requestId) { return passthrough() } - // Bypass requests with the explicit bypass header. - // Such requests can be issued by "ctx.fetch()". - const mswIntention = request.headers.get("x-msw-intention") - if (["bypass", "passthrough"].includes(mswIntention)) { - return passthrough() - } - // Notify the client that a request has been intercepted. - const requestBuffer = await request.arrayBuffer() + const serializedRequest = await serializeRequest(event.request) const clientMessage = await sendToClient( client, { type: "REQUEST", payload: { id: requestId, - url: request.url, - mode: request.mode, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - cache: request.cache, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body: requestBuffer, - keepalive: request.keepalive, + interceptedAt: requestInterceptedAt, + ...serializedRequest, }, }, - [requestBuffer], + [serializedRequest.body], ) switch (clientMessage.type) { @@ -240,7 +268,7 @@ async function getResponse(event, client, requestId) { return respondWithMock(clientMessage.data) } - case "MOCK_NOT_FOUND": { + case "PASSTHROUGH": { return passthrough() } } @@ -248,6 +276,12 @@ async function getResponse(event, client, requestId) { return passthrough() } +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ function sendToClient(client, message, transferrables = []) { return new Promise((resolve, reject) => { const channel = new MessageChannel() @@ -260,14 +294,18 @@ function sendToClient(client, message, transferrables = []) { resolve(event.data) } - client.postMessage( - message, - [channel.port2].concat(transferrables.filter(Boolean)), - ) + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) }) } -async function respondWithMock(response) { +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { // Setting response status code to 0 is a no-op. // However, when responding with a "Response.error()", the produced Response // instance will have status code set to 0. Since it's not possible to create @@ -285,3 +323,24 @@ async function respondWithMock(response) { return mockedResponse } + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +}