From 0aaeebbfff22094b939c99d8af89bdaa10cc4705 Mon Sep 17 00:00:00 2001 From: Alexandre Ablon Date: Thu, 20 Jul 2023 12:14:01 +0200 Subject: [PATCH 01/17] Clean up Rule editor function name Change translation for list empty and list value empty --- .../app-builder/public/locales/en/lists.json | 4 ++-- .../app-builder/public/locales/fr/lists.json | 6 +++--- .../src/components/Edit/EditAstNode.tsx | 2 -- .../src/components/Edit/RootOrWithAnd.tsx | 16 ++++++++-------- .../src/components/Scenario/LogicalOperator.tsx | 4 ++-- .../src/components/Scenario/Rule/Rule.tsx | 6 +++--- .../src/repositories/ScenarioRepository.ts | 8 ++++---- .../$scenarioId/i/$iterationId/edit/trigger.tsx | 4 ++-- .../$scenarioId/i/$iterationId/view/trigger.tsx | 4 ++-- 9 files changed, 26 insertions(+), 28 deletions(-) diff --git a/packages/app-builder/public/locales/en/lists.json b/packages/app-builder/public/locales/en/lists.json index 50ee5f6fd..799d92aec 100644 --- a/packages/app-builder/public/locales/en/lists.json +++ b/packages/app-builder/public/locales/en/lists.json @@ -7,8 +7,8 @@ "other_scenarios_one": "+{{count}} other", "other_scenarios_other": "+{{count}} others", "show_less": "show less", - "empty_custom_lists_list": "Reach out to Marble to create your first list.", - "empty_custom_list_values_list": "Create your first list value to see it here.", + "empty_custom_lists_list": "You do not have any list. Add your first one to see it here.", + "empty_custom_list_values_list": "This list is empty. Add its first value to see it here.", "create_list.title": "New List", "create_list.name_placeholder": "Add a name to your list", "create_list.description_placeholder": "Add a description", diff --git a/packages/app-builder/public/locales/fr/lists.json b/packages/app-builder/public/locales/fr/lists.json index b0c3f333a..b3b20c9a1 100644 --- a/packages/app-builder/public/locales/fr/lists.json +++ b/packages/app-builder/public/locales/fr/lists.json @@ -4,8 +4,9 @@ "other_scenarios_one": "{{count}} autre", "other_scenarios_other": "{{count}} autres", "show_less": "Montrer moins", - "used_in_scenarios": "Utilisé dans les scénarios suivants :", - "empty_custom_lists_list": "Contactez Marble pour créer votre première liste.", + "used_in_scenarios": "Utilisé dans les scénarios suivants :", + "empty_custom_lists_list": "Vous n'avez pas encore de liste. Créer votre première liste pour la voir s'afficher ici", + "empty_custom_list_values_list": "Cette liste est vide. Ajouter votre première valeur pour la voir s'afficher ici", "create_value.title": "Nouvelle valeur", "create_value.value_placeholder": "Ajouter une nouvelle valeur à votre liste", "create_list.button_accept": "Creer une nouvelle liste", @@ -22,5 +23,4 @@ "create_list.title": "Nouvelle liste", "create_list.name_placeholder": "Ajouter un nom à cette liste", "create_list.description_placeholder": "Ajouter une description", - "empty_custom_list_values_list": "Créez votre première valeur de liste pour la voir ici." } diff --git a/packages/app-builder/src/components/Edit/EditAstNode.tsx b/packages/app-builder/src/components/Edit/EditAstNode.tsx index 4e0ad23f1..352b56da6 100644 --- a/packages/app-builder/src/components/Edit/EditAstNode.tsx +++ b/packages/app-builder/src/components/Edit/EditAstNode.tsx @@ -109,7 +109,6 @@ function coerceToConstant(search: string) { return { label: search, node: NewAstNode({ - name: 'CONSTANT_FLOAT', constant: parsedNumber, }), }; @@ -118,7 +117,6 @@ function coerceToConstant(search: string) { return { label: `"${search}"`, node: NewAstNode({ - name: 'CONSTANT_STRING', constant: search, }), }; diff --git a/packages/app-builder/src/components/Edit/RootOrWithAnd.tsx b/packages/app-builder/src/components/Edit/RootOrWithAnd.tsx index 5875340b2..770fd340b 100644 --- a/packages/app-builder/src/components/Edit/RootOrWithAnd.tsx +++ b/packages/app-builder/src/components/Edit/RootOrWithAnd.tsx @@ -6,14 +6,14 @@ import * as React from 'react'; import { useFieldArray } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { LogicalOperator } from '../Scenario/LogicalOperator'; +import { LogicalOperatorLabel } from '../Scenario/LogicalOperator'; import { RemoveButton } from './RemoveButton'; type RootOrWithAndFormFields = { astNode: AstNode; }; -const AddLogicalOperator = React.forwardRef< +const AddLogicalOperatorButton = React.forwardRef< HTMLButtonElement, ButtonProps & { operator: 'if' | 'and' | 'or' | 'where'; @@ -27,7 +27,7 @@ const AddLogicalOperator = React.forwardRef< ); }); -AddLogicalOperator.displayName = 'AddLogicalOperator'; +AddLogicalOperatorButton.displayName = 'AddLogicalOperatorButton'; export function RootOrOperator({ renderAstNode, @@ -44,7 +44,7 @@ export function RootOrOperator({ function appendOrOperand() { append({ - name: 'AND', + name: 'And', children: [NewAstNode()], namedChildren: {}, constant: null, @@ -60,7 +60,7 @@ export function RootOrOperator({ {!isFirstOperand && (
- @@ -77,7 +77,7 @@ export function RootOrOperator({ ); })} - +
); } @@ -125,12 +125,12 @@ function RootAndOperator({
{renderAstNode({ name: `${name}.${operandIndex}` })}
- + ); })} - { return ( - @@ -56,7 +56,7 @@ export function Rule({ rule }: { rule: ScenarioIterationRule }) { )} {!isLastOperand && ( <> - diff --git a/packages/app-builder/src/repositories/ScenarioRepository.ts b/packages/app-builder/src/repositories/ScenarioRepository.ts index 286c26371..a07b497e3 100644 --- a/packages/app-builder/src/repositories/ScenarioRepository.ts +++ b/packages/app-builder/src/repositories/ScenarioRepository.ts @@ -4,11 +4,11 @@ import { adaptFormulaDto, type AstNode } from '@app-builder/models'; export type ScenarioRepository = ReturnType; export function isOrAndGroup(astNode: AstNode): boolean { - if (astNode.name !== 'OR') { + if (astNode.name !== 'Or') { return false; } for (const child of astNode.children) { - if (child.name !== 'AND') { + if (child.name !== 'And') { return false; } } @@ -17,11 +17,11 @@ export function isOrAndGroup(astNode: AstNode): boolean { export function wrapInOrAndGroups(astNode: AstNode): AstNode { return { - name: 'OR', + name: 'Or', constant: null, children: [ { - name: 'AND', + name: 'And', constant: null, children: [astNode], namedChildren: {}, diff --git a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit/trigger.tsx b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit/trigger.tsx index d3a0e1731..73f61bbc4 100644 --- a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit/trigger.tsx +++ b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit/trigger.tsx @@ -1,6 +1,6 @@ import { Callout, Paper } from '@app-builder/components'; import { Formula } from '@app-builder/components/Scenario/Formula'; -import { LogicalOperator } from '@app-builder/components/Scenario/LogicalOperator'; +import { LogicalOperatorLabel } from '@app-builder/components/Scenario/LogicalOperator'; import { ScenarioBox } from '@app-builder/components/Scenario/ScenarioBox'; import { type Operator } from '@marble-api'; import clsx from 'clsx'; @@ -147,7 +147,7 @@ function TriggerCondition({ )} />
- diff --git a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/view/trigger.tsx b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/view/trigger.tsx index 0bd1cc7d2..0f69f9df1 100644 --- a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/view/trigger.tsx +++ b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/view/trigger.tsx @@ -1,6 +1,6 @@ import { Callout, Paper } from '@app-builder/components'; import { Formula } from '@app-builder/components/Scenario/Formula'; -import { LogicalOperator } from '@app-builder/components/Scenario/LogicalOperator'; +import { LogicalOperatorLabel } from '@app-builder/components/Scenario/LogicalOperator'; import { ScenarioBox } from '@app-builder/components/Scenario/ScenarioBox'; import { type Operator } from '@marble-api'; import clsx from 'clsx'; @@ -177,7 +177,7 @@ function TriggerCondition() { )} />
- From 82c7bbfd4239bfb4760d167d5ce097f0fa4d0fd7 Mon Sep 17 00:00:00 2001 From: Alexandre Ablon Date: Thu, 20 Jul 2023 18:06:37 +0200 Subject: [PATCH 02/17] Add edit builder save and load (incomplete) --- .../app-builder/public/locales/en/common.json | 3 +- .../app-builder/public/locales/fr/common.json | 5 +- .../src/components/Edit/EditAstNode.tsx | 62 +++---- .../src/components/Edit/RootOrWithAnd.tsx | 8 +- .../src/components/Edit/WildEditAstNode.tsx | 10 +- packages/app-builder/src/models/ast-node.ts | 15 +- .../app-builder/src/models/marble-session.ts | 19 +- .../src/repositories/EditorRepository.ts | 16 +- .../src/repositories/ScenarioRepository.ts | 24 ++- .../i/$iterationId/edit.rules.$ruleId.tsx | 171 ++++++++++-------- packages/marble-api/scripts/openapi.yaml | 39 ++++ .../marble-api/src/generated/marble-api.ts | 46 ++++- .../ui-design-system/src/Button/Button.tsx | 1 + 13 files changed, 267 insertions(+), 152 deletions(-) diff --git a/packages/app-builder/public/locales/en/common.json b/packages/app-builder/public/locales/en/common.json index ee968f277..7ebfc9ea7 100644 --- a/packages/app-builder/public/locales/en/common.json +++ b/packages/app-builder/public/locales/en/common.json @@ -10,5 +10,6 @@ "delete": "Delete", "close": "Close", "clipboard.copy": "Copied in clipboard: {{value}}", - "empty_scenario_iteration_list": "Reach out to Marble to create your first scenario draft." + "empty_scenario_iteration_list": "Reach out to Marble to create your first scenario draft.", + "success.save": "Saved successfully" } diff --git a/packages/app-builder/public/locales/fr/common.json b/packages/app-builder/public/locales/fr/common.json index f3e0d75e3..3bdd82f91 100644 --- a/packages/app-builder/public/locales/fr/common.json +++ b/packages/app-builder/public/locales/fr/common.json @@ -2,7 +2,7 @@ "auth.logout": "Se déconnecter", "auth.login": "Connexion", "cancel": "Annuler", - "clipboard.copy": "Copié dans le presse-papier : {{value}}", + "clipboard.copy": "Copié dans le presse-papier : {{value}}", "close": "Fermer", "errors.unknown": "Une erreur inconnue s'est produite", "errors.edit.forbidden_not_draft": "Vous ne pouvez modifier qu'une version brouillon d'un scénario.", @@ -10,5 +10,6 @@ "search": "Recherche", "empty_scenario_iteration_list": "Contactez Marble pour créer votre premier brouillon de scénario.", "delete": "Supprimer", - "save": "Sauvegarder" + "save": "Sauvegarder", + "success.save": "Sauvegarde réussie" } diff --git a/packages/app-builder/src/components/Edit/EditAstNode.tsx b/packages/app-builder/src/components/Edit/EditAstNode.tsx index 352b56da6..fd158d37a 100644 --- a/packages/app-builder/src/components/Edit/EditAstNode.tsx +++ b/packages/app-builder/src/components/Edit/EditAstNode.tsx @@ -2,15 +2,18 @@ import { type AstNode, NewAstNode } from '@app-builder/models'; import { useEditorIdentifiers } from '@app-builder/services/editor'; import { Combobox, Select } from '@ui-design-system'; import { forwardRef, useCallback, useState } from 'react'; -import { useFormContext } from 'react-hook-form'; +import { useWatch } from 'react-hook-form'; import { FormControl, FormField, FormItem } from '../Form'; -import { useGetOperatorLabel } from '../Scenario/Formula/Operators'; +//import { useGetOperatorLabel } from '../Scenario/Formula/Operators'; + +function isAstNodeEmpty(node: AstNode): boolean { + return !node.name && !node.constant && node.children?.length === 0 && Object.keys(node.namedChildren).length ===0; +} export function EditAstNode({ name }: { name: string }) { - const { getFieldState, formState } = useFormContext(); - const firstChildState = getFieldState(`${name}.children.0`, formState); - const nameState = getFieldState(`${name}.name`, formState); + const firstChildNode = useWatch(`${name}.children.0`); + const nameNode = useWatch(`${name}.name`); return (
@@ -26,9 +29,8 @@ export function EditAstNode({ name }: { name: string }) { /> ( - + @@ -38,7 +40,7 @@ export function EditAstNode({ name }: { name: string }) { ( - + @@ -48,31 +50,41 @@ export function EditAstNode({ name }: { name: string }) {
); } +function getSelectedItem({value}: {value: AstNode | null }){ + if(!value) return null + + if (!value.name && value.constant) { + return {label: value.constant.toString(), node: value} + } + return null +} //TODO: connect value to Combobox (we may need to save {label:string; node: AstNode} in the form to ease the process) const EditOperand = forwardRef< HTMLInputElement, { name: string; - value: string | null; + value: AstNode | null; onChange: (value: AstNode | null) => void; onBlur: () => void; } ->(({ onChange, onBlur }, ref) => { +>(({ onChange, onBlur, value }, ref) => { const getIdentifierOptions = useGetIdentifierOptions(); - const [inputValue, setInputValue] = useState(''); - const [selectedItem, setSelectedItem] = useState< - ReturnType[number] | null - >(null); + + const selectedItem = getSelectedItem({value}) + + const [inputValue, setInputValue] = useState(selectedItem?.label ?? ""); + const items = getIdentifierOptions(inputValue); const filteredItems = items.filter((item) => item.label.includes(inputValue)); + + items.find((item) => item.node === value) return ( { - setSelectedItem(value); onChange(value?.node ?? null); }} nullable @@ -143,7 +155,7 @@ const EditOperator = forwardRef< onBlur: () => void; } >(({ name, value, onChange, onBlur }, ref) => { - const getOperatorLabel = useGetOperatorLabel(); + // const getOperatorLabel = useGetOperatorLabel(); return ( - {getOperatorLabel(operator)} + {/* {getOperatorLabel(operator)} */} + {operator} {operator} @@ -188,18 +201,5 @@ const EditOperator = forwardRef< EditOperator.displayName = 'EditOperator'; const mockedOperators = [ - 'EQUAL_BOOL', - 'EQUAL_FLOAT', - 'EQUAL_STRING', - 'AND', - 'PRODUCT_FLOAT', - 'OR', - 'SUM_FLOAT', - 'SUBTRACT_FLOAT', - 'DIVIDE_FLOAT', - 'GREATER_FLOAT', - 'GREATER_OR_EQUAL_FLOAT', - 'LESSER_FLOAT', - 'LESSER_OR_EQUAL_FLOAT', - 'STRING_IS_IN_LIST', + '=', ] as const; diff --git a/packages/app-builder/src/components/Edit/RootOrWithAnd.tsx b/packages/app-builder/src/components/Edit/RootOrWithAnd.tsx index 770fd340b..391dfccdb 100644 --- a/packages/app-builder/src/components/Edit/RootOrWithAnd.tsx +++ b/packages/app-builder/src/components/Edit/RootOrWithAnd.tsx @@ -29,6 +29,10 @@ const AddLogicalOperatorButton = React.forwardRef< }); AddLogicalOperatorButton.displayName = 'AddLogicalOperatorButton'; +function NewBinaryAstNode(){ + return NewAstNode({children: [NewAstNode(),NewAstNode()]} ) +} + export function RootOrOperator({ renderAstNode, }: { @@ -45,7 +49,7 @@ export function RootOrOperator({ function appendOrOperand() { append({ name: 'And', - children: [NewAstNode()], + children: [NewBinaryAstNode()], namedChildren: {}, constant: null, }); @@ -105,7 +109,7 @@ function RootAndOperator({ } function appendAndOperand() { - append(NewAstNode()); + append(NewBinaryAstNode()); } return ( diff --git a/packages/app-builder/src/components/Edit/WildEditAstNode.tsx b/packages/app-builder/src/components/Edit/WildEditAstNode.tsx index 78760efcd..a71633410 100644 --- a/packages/app-builder/src/components/Edit/WildEditAstNode.tsx +++ b/packages/app-builder/src/components/Edit/WildEditAstNode.tsx @@ -1,4 +1,4 @@ -import { type AstNode, NewAstNode, NoConstant } from '@app-builder/models'; +import { type AstNode, NewAstNode } from '@app-builder/models'; import { Button, Input } from '@ui-design-system'; import { useFieldArray } from 'react-hook-form'; @@ -42,13 +42,9 @@ export function WildEditAstNode({ { - field.onChange(event.target.value || NoConstant); + field.onChange(event.target.value ?? null); }} /> diff --git a/packages/app-builder/src/models/ast-node.ts b/packages/app-builder/src/models/ast-node.ts index b03b37377..74bb11ae7 100644 --- a/packages/app-builder/src/models/ast-node.ts +++ b/packages/app-builder/src/models/ast-node.ts @@ -11,7 +11,7 @@ import { export interface AstNode { name: string | null; - constant: ConstantOptional; + constant: ConstantType | null; children: AstNode[]; namedChildren: Record; } @@ -24,8 +24,6 @@ export type ConstantType = | Array | { [key: string]: ConstantType }; -export const NoConstant: unique symbol = Symbol(); -export type ConstantOptional = ConstantType | typeof NoConstant; // helper export function NewAstNode({ @@ -36,7 +34,7 @@ export function NewAstNode({ }: Partial = {}): AstNode { return { name: name ?? null, - constant: constant ?? NoConstant, + constant: constant ?? null, children: children ?? [], namedChildren: namedChildren ?? {}, }; @@ -121,3 +119,12 @@ export function adaptNodeDto(nodeDto: NodeDto): AstNode { namedChildren: R.mapValues(nodeDto.named_children ?? {}, adaptNodeDto), }); } + +export function adaptAstNode(astNode: AstNode): NodeDto { + return { + name: astNode.name ?? undefined, + constant: astNode.constant ?? undefined, + children: astNode.children?.map(adaptAstNode), + named_children: R.mapValues(astNode.namedChildren ?? {}, adaptAstNode), + }; +} diff --git a/packages/app-builder/src/models/marble-session.ts b/packages/app-builder/src/models/marble-session.ts index e51a8a8f7..e04ef0eea 100644 --- a/packages/app-builder/src/models/marble-session.ts +++ b/packages/app-builder/src/models/marble-session.ts @@ -1,16 +1,17 @@ -import { type Token } from '@marble-api'; -import { type Session } from '@remix-run/node'; -import * as z from 'zod'; +import { type Token } from "@marble-api"; +import { type Session } from "@remix-run/node"; +import * as z from "zod"; -import { type AuthErrors } from './auth-errors'; +import { type AuthErrors } from "./auth-errors"; export const toastMessageScema = z.object({ - type: z.enum(['success', 'error', 'loading', 'custom']), + type: z.enum(["success", "error", "loading", "custom"]), messageKey: z.enum([ - 'common:errors.unknown', - 'common:empty_scenario_iteration_list', - 'common:errors.edit.forbidden_not_draft', - 'common:errors.list.duplicate_list_name', + "common:errors.unknown", + "common:empty_scenario_iteration_list", + "common:errors.edit.forbidden_not_draft", + "common:errors.list.duplicate_list_name", + "common:success.save", ]), }); diff --git a/packages/app-builder/src/repositories/EditorRepository.ts b/packages/app-builder/src/repositories/EditorRepository.ts index b6144e304..30b2823d1 100644 --- a/packages/app-builder/src/repositories/EditorRepository.ts +++ b/packages/app-builder/src/repositories/EditorRepository.ts @@ -1,5 +1,5 @@ -import { type MarbleApi } from '@app-builder/infra/marble-api'; -import { adaptNodeDto } from '@app-builder/models'; +import { type MarbleApi } from "@app-builder/infra/marble-api"; +import { adaptAstNode,adaptNodeDto, type AstNode } from "@app-builder/models"; export type EditorRepository = ReturnType; @@ -14,5 +14,17 @@ export function getEditorRepository() { return { dataAccessors }; }, + saveRule: async ({ + ruleId, + astNode, + }: { + ruleId: string; + astNode: AstNode; + }) => { + return marbleApiClient.saveRule({ + rule_id: ruleId, + expression: adaptAstNode(astNode), + }); + }, }); } diff --git a/packages/app-builder/src/repositories/ScenarioRepository.ts b/packages/app-builder/src/repositories/ScenarioRepository.ts index a07b497e3..201f443ef 100644 --- a/packages/app-builder/src/repositories/ScenarioRepository.ts +++ b/packages/app-builder/src/repositories/ScenarioRepository.ts @@ -1,29 +1,29 @@ -import { type MarbleApi } from '@app-builder/infra/marble-api'; -import { adaptFormulaDto, type AstNode } from '@app-builder/models'; +import { type MarbleApi } from "@app-builder/infra/marble-api"; +import { adaptNodeDto, type AstNode } from "@app-builder/models"; export type ScenarioRepository = ReturnType; export function isOrAndGroup(astNode: AstNode): boolean { - if (astNode.name !== 'Or') { + if (astNode.name !== "Or") { return false; } for (const child of astNode.children) { - if (child.name !== 'And') { + if (child.name !== "And") { return false; } } return true; } -export function wrapInOrAndGroups(astNode: AstNode): AstNode { +export function wrapInOrAndGroups(astNode?: AstNode): AstNode { return { - name: 'Or', + name: "Or", constant: null, children: [ { - name: 'And', + name: "And", constant: null, - children: [astNode], + children: astNode ? [astNode] : [], namedChildren: {}, }, ], @@ -34,10 +34,14 @@ export function wrapInOrAndGroups(astNode: AstNode): AstNode { export function getScenarioRepository() { return (marbleApiClient: MarbleApi) => ({ getScenarioIterationRule: async ({ ruleId }: { ruleId: string }) => { - const { formula, ...rule } = + const { formula_ast_expression, ...rule } = await marbleApiClient.getScenarioIterationRule(ruleId); - const astNode = adaptFormulaDto(formula); + if (!formula_ast_expression) { + return { ...rule, astNode: wrapInOrAndGroups() }; + } + + const astNode = adaptNodeDto(formula_ast_expression); const orAndGroupAstNode = isOrAndGroup(astNode) ? astNode diff --git a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx index 033bde2ae..2486514e1 100644 --- a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx +++ b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx @@ -3,34 +3,35 @@ import { Paper, scenarioI18n, ScenarioPage, -} from '@app-builder/components'; -import { EditAstNode, RootOrOperator } from '@app-builder/components/Edit'; -import { Consequence } from '@app-builder/components/Scenario/Rule/Consequence'; -import { type AstNode } from '@app-builder/models'; -import { EditorIdentifiersProvider } from '@app-builder/services/editor'; -import { serverServices } from '@app-builder/services/init.server'; -import { fromParams, fromUUID } from '@app-builder/utils/short-uuid'; -import { DevTool } from '@hookform/devtools'; -import { json, type LoaderArgs } from '@remix-run/node'; -import { Link, useLoaderData } from '@remix-run/react'; -import { Button, Tag } from '@ui-design-system'; -import { type Namespace } from 'i18next'; -import { FormProvider, useForm } from 'react-hook-form'; -import toast from 'react-hot-toast'; -import { ClientOnly } from 'remix-utils'; +} from "@app-builder/components"; +import { EditAstNode, RootOrOperator } from "@app-builder/components/Edit"; +import { setToastMessage } from "@app-builder/components/MarbleToaster"; +import { Consequence } from "@app-builder/components/Scenario/Rule/Consequence"; +import { type AstNode } from "@app-builder/models"; +import { EditorIdentifiersProvider } from "@app-builder/services/editor"; +import { serverServices } from "@app-builder/services/init.server"; +import { fromParams, fromUUID } from "@app-builder/utils/short-uuid"; +import { DevTool } from "@hookform/devtools"; +import { type ActionArgs, json, type LoaderArgs } from "@remix-run/node"; +import { Link, useFetcher, useLoaderData } from "@remix-run/react"; +import { Button, Tag } from "@ui-design-system"; +import { type Namespace } from "i18next"; +import { Form, FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { ClientOnly } from "remix-utils"; export const handle = { - i18n: [...scenarioI18n] satisfies Namespace, + i18n: [...scenarioI18n, "common"] satisfies Namespace, }; export async function loader({ request, params }: LoaderArgs) { const { authService } = serverServices; const { editor, scenario } = await authService.isAuthenticated(request, { - failureRedirect: '/login', + failureRedirect: "/login", }); - const ruleId = fromParams(params, 'ruleId'); - const scenarioId = fromParams(params, 'scenarioId'); + const ruleId = fromParams(params, "ruleId"); + const scenarioId = fromParams(params, "scenarioId"); const scenarioIterationRule = scenario.getScenarioIterationRule({ ruleId, @@ -46,14 +47,59 @@ export async function loader({ request, params }: LoaderArgs) { }); } +export async function action({ request, params }: ActionArgs) { + const { authService,sessionService: { getSession, commitSession }} = serverServices; + const session = await getSession(request); + const { editor } = await authService.isAuthenticated(request, { + failureRedirect: "/login", + }); + + try { + const ruleId = fromParams(params, "ruleId"); + + const expression = (await request.json()) as FormValues; + + await editor.saveRule({ ruleId, astNode: expression.astNode }); + + setToastMessage(session, { + type: "success", + messageKey: "common:success.save", + }); + return json({ + success: true as const, + error: null, + values: expression, + }, + { headers: { "Set-Cookie": await commitSession(session) } }); + } catch (error) { + setToastMessage(session, { + type: "error", + messageKey: "common:errors.unknown", + }); + + return json( + { + success: false as const, + error: null, + values: null, + }, + { headers: { "Set-Cookie": await commitSession(session) } } + ); + } +} + +interface FormValues { + astNode: AstNode; +} + export default function RuleView() { + const { t } = useTranslation(handle.i18n); const { rule, identifiers } = useLoaderData(); - - const formMethods = useForm<{ astNode: AstNode }>({ + console.log("view ast", JSON.stringify(rule.astNode, null, 2)); + const fetcher = useFetcher(); + const formMethods = useForm({ // TODO(builder): defaultValues is not working - // defaultValues: { - // astNode: rule.astNode, - // }, + defaultValues: { astNode: rule.astNode as AstNode }, }); return ( @@ -72,64 +118,41 @@ export default function RuleView() { {rule.description} -
- - - - - {/* { + fetcher.submit(data, { + method: "PATCH", + encType: "application/json", + }); + }} + > +
+ + + + + {/* } /> */} - } - /> - - - - -
+ } + /> +
+
+
+ +
+ {() => ( )} diff --git a/packages/marble-api/scripts/openapi.yaml b/packages/marble-api/scripts/openapi.yaml index 974134570..47cccd036 100644 --- a/packages/marble-api/scripts/openapi.yaml +++ b/packages/marble-api/scripts/openapi.yaml @@ -1440,6 +1440,31 @@ paths: $ref: '#/components/responses/401' '403': $ref: '#/components/responses/403' + /ast-expression/save-rule: + patch: + tags: + - Editor + summary: Save a rule + operationId: saveRule + security: + - bearerAuth: [] + requestBody: + description: The rule to save + content: + application/json: + schema: + $ref: '#/components/schemas/PatchRuleWithAstExpression' + required: true + responses: + '204': + description: The rule has been saved + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + components: schemas: Organization: @@ -1932,6 +1957,9 @@ components: formula: $ref: '#/components/schemas/Operator' description: Valid marshalled operator + formula_ast_expression: + $ref: '#/components/schemas/NodeDto' + description: Valid marshalled ast_node scoreModifier: type: integer createdAt: @@ -2464,6 +2492,17 @@ components: properties: level: type: integer + PatchRuleWithAstExpression: + type: object + required: + - rule_id + - expression + properties: + rule_id: + type: string + format: uuid + expression: + $ref: '#/components/schemas/NodeDto' securitySchemes: bearerAuth: type: http diff --git a/packages/marble-api/src/generated/marble-api.ts b/packages/marble-api/src/generated/marble-api.ts index 94d38bac5..0e546b491 100644 --- a/packages/marble-api/src/generated/marble-api.ts +++ b/packages/marble-api/src/generated/marble-api.ts @@ -263,6 +263,17 @@ export type CreateScenarioIterationBody = { rules?: CreateScenarioIterationRuleBody[]; }; }; +export type ConstantDto = (string | number | boolean | ConstantDto[] | { + [key: string]: ConstantDto; +}) | null; +export type NodeDto = { + name?: string; + constant?: ConstantDto; + children?: NodeDto[]; + named_children?: { + [key: string]: NodeDto; + }; +}; export type ScenarioIterationRule = { id: string; scenarioIterationId: string; @@ -270,6 +281,7 @@ export type ScenarioIterationRule = { name: string; description: string; formula: Operator; + formula_ast_expression?: NodeDto; scoreModifier: number; createdAt: string; }; @@ -357,16 +369,9 @@ export type UpdateOrganizationBodyDto = { name?: string; database_name?: string; }; -export type ConstantDto = (string | number | boolean | ConstantDto[] | { - [key: string]: ConstantDto; -}) | null; -export type NodeDto = { - name?: string; - constant?: ConstantDto; - children?: NodeDto[]; - named_children?: { - [key: string]: NodeDto; - }; +export type PatchRuleWithAstExpression = { + rule_id: string; + expression: NodeDto; }; /** * Get an access token @@ -1341,3 +1346,24 @@ export function listIdentifiers(scenarioId: string, opts?: Oazapfts.RequestOpts) ...opts })); } +/** + * Save a rule + */ +export function saveRule(patchRuleWithAstExpression: PatchRuleWithAstExpression, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 204; + } | { + status: 401; + data: string; + } | { + status: 403; + data: string; + } | { + status: 404; + data: string; + }>("/ast-expression/save-rule", oazapfts.json({ + ...opts, + method: "PATCH", + body: patchRuleWithAstExpression + }))); +} diff --git a/packages/ui-design-system/src/Button/Button.tsx b/packages/ui-design-system/src/Button/Button.tsx index d304a61e5..cf0fbc3cf 100644 --- a/packages/ui-design-system/src/Button/Button.tsx +++ b/packages/ui-design-system/src/Button/Button.tsx @@ -40,6 +40,7 @@ export const Button = forwardRef( return (
); })} diff --git a/packages/app-builder/src/components/Edit/WildEditAstNode.tsx b/packages/app-builder/src/components/Edit/WildEditAstNode.tsx index a71633410..143e0ed80 100644 --- a/packages/app-builder/src/components/Edit/WildEditAstNode.tsx +++ b/packages/app-builder/src/components/Edit/WildEditAstNode.tsx @@ -42,7 +42,7 @@ export function WildEditAstNode({ { field.onChange(event.target.value ?? null); }} diff --git a/packages/app-builder/src/components/Scenario/LogicalOperator.tsx b/packages/app-builder/src/components/Scenario/LogicalOperator.tsx index 5ace6242b..af738d7df 100644 --- a/packages/app-builder/src/components/Scenario/LogicalOperator.tsx +++ b/packages/app-builder/src/components/Scenario/LogicalOperator.tsx @@ -8,7 +8,10 @@ interface LogicalOperatorLabelProps { className?: string; } -export function LogicalOperatorLabel({ operator, className }: LogicalOperatorLabelProps) { +export function LogicalOperatorLabel({ + operator, + className, +}: LogicalOperatorLabelProps) { const { t } = useTranslation(scenarioI18n); return ( diff --git a/packages/app-builder/src/models/ast-node.ts b/packages/app-builder/src/models/ast-node.ts index 74bb11ae7..0d03dd394 100644 --- a/packages/app-builder/src/models/ast-node.ts +++ b/packages/app-builder/src/models/ast-node.ts @@ -24,7 +24,6 @@ export type ConstantType = | Array | { [key: string]: ConstantType }; - // helper export function NewAstNode({ name, @@ -128,3 +127,37 @@ export function adaptAstNode(astNode: AstNode): NodeDto { named_children: R.mapValues(astNode.namedChildren ?? {}, adaptAstNode), }; } + +export function isAstNodeEmpty(node: AstNode): boolean { + return ( + !node.name && + !node.constant && + node.children?.length === 0 && + Object.keys(node.namedChildren).length === 0 + ); +} + +export interface ConstantAstNode { + name: null; + constant: T; + children: []; + namedChildren: Record; +} + +export function isConstant(node: AstNode): node is ConstantAstNode { + return !node.name && !!node.constant; +} + +export interface DatabaseAccessAstNode { + name: 'DatabaseAccess'; + constant: null; + children: []; + namedChildren: { + path: ConstantAstNode; + fieldName: ConstantAstNode; + }; +} + +export function isDatabaseAccess(node: AstNode): node is DatabaseAccessAstNode { + return node.name === 'DatabaseAccess'; +} diff --git a/packages/app-builder/src/models/ast-view-model.ts b/packages/app-builder/src/models/ast-view-model.ts new file mode 100644 index 000000000..e29c1384a --- /dev/null +++ b/packages/app-builder/src/models/ast-view-model.ts @@ -0,0 +1,61 @@ +import { + type AstNode, + type ConstantType, + isAstNodeEmpty, + isConstant, + isDatabaseAccess, +} from './ast-node'; + +export interface AstViewModel { + label: string; + astNode: AstNode; +} + +export function adaptAstNodeToViewModel(astNode: AstNode): AstViewModel { + return { + label: getAstNodeDisplayName(astNode), + astNode, + }; +} + +export function adaptAstViewModelToAstNode( + astViewModel: AstViewModel +): AstNode { + return astViewModel.astNode; +} + +function getConstantDisplayName(constant: ConstantType) { + if (constant === null) return ''; + + if (typeof constant === 'string') { + return `"${constant}"`; + } + + if (typeof constant === 'number') { + return constant.toString(); + } + + // Handle other cases when needed + return constant.toString(); +} + +function getAstNodeDisplayName(astNode: AstNode) { + if (isConstant(astNode)) { + return getConstantDisplayName(astNode.constant); + } + + if (isDatabaseAccess(astNode)) { + const { path, fieldName } = astNode.namedChildren; + return [...path.constant, fieldName.constant].join('.'); + } + + if (isAstNodeEmpty(astNode)) { + return ''; + } + + // eslint-disable-next-line no-restricted-properties + if (process.env.NODE_ENV === 'development') { + console.warn('Unhandled astNode', astNode); + } + return ''; +} diff --git a/packages/app-builder/src/models/index.ts b/packages/app-builder/src/models/index.ts index 6f4d45b60..852b7c456 100644 --- a/packages/app-builder/src/models/index.ts +++ b/packages/app-builder/src/models/index.ts @@ -1,4 +1,5 @@ export * from './ast-node'; +export * from './ast-view-model'; export * from './auth-errors'; export * from './http-errors'; export * from './marble-session'; diff --git a/packages/app-builder/src/models/marble-session.ts b/packages/app-builder/src/models/marble-session.ts index e04ef0eea..91d07a0c2 100644 --- a/packages/app-builder/src/models/marble-session.ts +++ b/packages/app-builder/src/models/marble-session.ts @@ -1,17 +1,17 @@ -import { type Token } from "@marble-api"; -import { type Session } from "@remix-run/node"; -import * as z from "zod"; +import { type Token } from '@marble-api'; +import { type Session } from '@remix-run/node'; +import * as z from 'zod'; -import { type AuthErrors } from "./auth-errors"; +import { type AuthErrors } from './auth-errors'; export const toastMessageScema = z.object({ - type: z.enum(["success", "error", "loading", "custom"]), + type: z.enum(['success', 'error', 'loading', 'custom']), messageKey: z.enum([ - "common:errors.unknown", - "common:empty_scenario_iteration_list", - "common:errors.edit.forbidden_not_draft", - "common:errors.list.duplicate_list_name", - "common:success.save", + 'common:errors.unknown', + 'common:empty_scenario_iteration_list', + 'common:errors.edit.forbidden_not_draft', + 'common:errors.list.duplicate_list_name', + 'common:success.save', ]), }); diff --git a/packages/app-builder/src/repositories/EditorRepository.ts b/packages/app-builder/src/repositories/EditorRepository.ts index 30b2823d1..cd12c7b0c 100644 --- a/packages/app-builder/src/repositories/EditorRepository.ts +++ b/packages/app-builder/src/repositories/EditorRepository.ts @@ -1,5 +1,5 @@ -import { type MarbleApi } from "@app-builder/infra/marble-api"; -import { adaptAstNode,adaptNodeDto, type AstNode } from "@app-builder/models"; +import { type MarbleApi } from '@app-builder/infra/marble-api'; +import { adaptAstNode, adaptNodeDto, type AstNode } from '@app-builder/models'; export type EditorRepository = ReturnType; diff --git a/packages/app-builder/src/repositories/ScenarioRepository.ts b/packages/app-builder/src/repositories/ScenarioRepository.ts index 201f443ef..66612ac11 100644 --- a/packages/app-builder/src/repositories/ScenarioRepository.ts +++ b/packages/app-builder/src/repositories/ScenarioRepository.ts @@ -1,14 +1,14 @@ -import { type MarbleApi } from "@app-builder/infra/marble-api"; -import { adaptNodeDto, type AstNode } from "@app-builder/models"; +import { type MarbleApi } from '@app-builder/infra/marble-api'; +import { adaptNodeDto, type AstNode } from '@app-builder/models'; export type ScenarioRepository = ReturnType; export function isOrAndGroup(astNode: AstNode): boolean { - if (astNode.name !== "Or") { + if (astNode.name !== 'Or') { return false; } for (const child of astNode.children) { - if (child.name !== "And") { + if (child.name !== 'And') { return false; } } @@ -17,11 +17,11 @@ export function isOrAndGroup(astNode: AstNode): boolean { export function wrapInOrAndGroups(astNode?: AstNode): AstNode { return { - name: "Or", + name: 'Or', constant: null, children: [ { - name: "And", + name: 'And', constant: null, children: astNode ? [astNode] : [], namedChildren: {}, diff --git a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx index 2486514e1..e36564c7c 100644 --- a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx +++ b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx @@ -3,35 +3,35 @@ import { Paper, scenarioI18n, ScenarioPage, -} from "@app-builder/components"; -import { EditAstNode, RootOrOperator } from "@app-builder/components/Edit"; -import { setToastMessage } from "@app-builder/components/MarbleToaster"; -import { Consequence } from "@app-builder/components/Scenario/Rule/Consequence"; -import { type AstNode } from "@app-builder/models"; -import { EditorIdentifiersProvider } from "@app-builder/services/editor"; -import { serverServices } from "@app-builder/services/init.server"; -import { fromParams, fromUUID } from "@app-builder/utils/short-uuid"; -import { DevTool } from "@hookform/devtools"; -import { type ActionArgs, json, type LoaderArgs } from "@remix-run/node"; -import { Link, useFetcher, useLoaderData } from "@remix-run/react"; -import { Button, Tag } from "@ui-design-system"; -import { type Namespace } from "i18next"; -import { Form, FormProvider, useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; -import { ClientOnly } from "remix-utils"; +} from '@app-builder/components'; +import { EditAstNode, RootOrOperator } from '@app-builder/components/Edit'; +import { setToastMessage } from '@app-builder/components/MarbleToaster'; +import { Consequence } from '@app-builder/components/Scenario/Rule/Consequence'; +import { type AstNode } from '@app-builder/models'; +import { EditorIdentifiersProvider } from '@app-builder/services/editor'; +import { serverServices } from '@app-builder/services/init.server'; +import { fromParams, fromUUID } from '@app-builder/utils/short-uuid'; +import { DevTool } from '@hookform/devtools'; +import { type ActionArgs, json, type LoaderArgs } from '@remix-run/node'; +import { Link, useFetcher, useLoaderData } from '@remix-run/react'; +import { Button, Tag } from '@ui-design-system'; +import { type Namespace } from 'i18next'; +import { Form, FormProvider, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { ClientOnly } from 'remix-utils'; export const handle = { - i18n: [...scenarioI18n, "common"] satisfies Namespace, + i18n: [...scenarioI18n, 'common'] satisfies Namespace, }; export async function loader({ request, params }: LoaderArgs) { const { authService } = serverServices; const { editor, scenario } = await authService.isAuthenticated(request, { - failureRedirect: "/login", + failureRedirect: '/login', }); - const ruleId = fromParams(params, "ruleId"); - const scenarioId = fromParams(params, "scenarioId"); + const ruleId = fromParams(params, 'ruleId'); + const scenarioId = fromParams(params, 'scenarioId'); const scenarioIterationRule = scenario.getScenarioIterationRule({ ruleId, @@ -48,33 +48,40 @@ export async function loader({ request, params }: LoaderArgs) { } export async function action({ request, params }: ActionArgs) { - const { authService,sessionService: { getSession, commitSession }} = serverServices; + const { + authService, + sessionService: { getSession, commitSession }, + } = serverServices; const session = await getSession(request); const { editor } = await authService.isAuthenticated(request, { - failureRedirect: "/login", + failureRedirect: '/login', }); try { - const ruleId = fromParams(params, "ruleId"); + const ruleId = fromParams(params, 'ruleId'); - const expression = (await request.json()) as FormValues; + const expression = (await request.json()) as { + astNode: AstNode; + }; await editor.saveRule({ ruleId, astNode: expression.astNode }); setToastMessage(session, { - type: "success", - messageKey: "common:success.save", + type: 'success', + messageKey: 'common:success.save', }); - return json({ - success: true as const, - error: null, - values: expression, - }, - { headers: { "Set-Cookie": await commitSession(session) } }); + return json( + { + success: true as const, + error: null, + values: expression, + }, + { headers: { 'Set-Cookie': await commitSession(session) } } + ); } catch (error) { setToastMessage(session, { - type: "error", - messageKey: "common:errors.unknown", + type: 'error', + messageKey: 'common:errors.unknown', }); return json( @@ -83,23 +90,19 @@ export async function action({ request, params }: ActionArgs) { error: null, values: null, }, - { headers: { "Set-Cookie": await commitSession(session) } } + { headers: { 'Set-Cookie': await commitSession(session) } } ); } } -interface FormValues { - astNode: AstNode; -} - export default function RuleView() { const { t } = useTranslation(handle.i18n); const { rule, identifiers } = useLoaderData(); - console.log("view ast", JSON.stringify(rule.astNode, null, 2)); + const fetcher = useFetcher(); - const formMethods = useForm({ - // TODO(builder): defaultValues is not working - defaultValues: { astNode: rule.astNode as AstNode }, + //@ts-expect-error recursive type is not supported + const formMethods = useForm({ + defaultValues: { astNode: rule.astNode }, }); return ( @@ -122,8 +125,8 @@ export default function RuleView() { control={formMethods.control} onSubmit={({ data }) => { fetcher.submit(data, { - method: "PATCH", - encType: "application/json", + method: 'PATCH', + encType: 'application/json', }); }} > @@ -142,7 +145,7 @@ export default function RuleView() {
@@ -152,7 +155,7 @@ export default function RuleView() { control={formMethods.control} placement="bottom-right" styles={{ - panel: { width: "450px" }, + panel: { width: '450px' }, }} /> )} diff --git a/packages/app-builder/src/services/editor/ast-expression.tsx b/packages/app-builder/src/services/editor/ast-expression.tsx new file mode 100644 index 000000000..f6248f631 --- /dev/null +++ b/packages/app-builder/src/services/editor/ast-expression.tsx @@ -0,0 +1,38 @@ +import { type AstNode, isAstNodeEmpty } from '@app-builder/models'; +import { useEffect, useState } from 'react'; +import { useWatch } from 'react-hook-form'; + +function useWatchAstNode< + TName extends `${string}.children.${number}` | `${string}.name` +>( + name: TName +): TName extends `${string}.name` + ? AstNode['name'] + : AstNode['children'][number] { + return useWatch({ name }); +} + +function isAstNodeFieldEmpty(field: ReturnType) { + if (field === null) return true; + + if (typeof field === 'string') return !field; + + return isAstNodeEmpty(field); +} + +/** + * Used to determine if the field has been edited once. + * It is initialized with the value of the field and should be updated when the field is edited. + */ +export function useIsEditedOnce( + name: `${string}.children.${number}` | `${string}.name` +) { + const field = useWatchAstNode(name); + const [isEditedOnce, setIsEditedOnce] = useState(!isAstNodeFieldEmpty(field)); + useEffect(() => { + if (!isEditedOnce && !isAstNodeFieldEmpty(field)) { + setIsEditedOnce(true); + } + }, [field, isEditedOnce]); + return isEditedOnce; +} diff --git a/packages/app-builder/src/services/editor/identifiers.tsx b/packages/app-builder/src/services/editor/identifiers.tsx index e95cda30f..46ad7a004 100644 --- a/packages/app-builder/src/services/editor/identifiers.tsx +++ b/packages/app-builder/src/services/editor/identifiers.tsx @@ -1,19 +1,13 @@ -import { type AstNode } from '@app-builder/models'; +import { + adaptAstNodeToViewModel, + type AstNode, + NewAstNode, +} from '@app-builder/models'; import { createSimpleContext } from '@app-builder/utils/create-context'; - -function getIdentifierDisplayName(identifiers: AstNode) { - switch (identifiers.name) { - case 'DatabaseAccess': { - const { path, fieldName } = identifiers.namedChildren; - return [...(path.constant as string[]), fieldName.constant].join('.'); - } - default: - return undefined; - } -} +import { useCallback, useMemo } from 'react'; const EditorIdentifiersContext = - createSimpleContext<{ label: string; node: AstNode }[]>('EditorIdentifiers'); + createSimpleContext('EditorIdentifiers'); export function EditorIdentifiersProvider({ children, @@ -24,15 +18,7 @@ export function EditorIdentifiersProvider({ dataAccessors: AstNode[]; }; }) { - const value = identifiers.dataAccessors - .map((dataAccessor) => ({ - label: getIdentifierDisplayName(dataAccessor), - node: dataAccessor, - })) - .filter( - (identifier): identifier is { label: string; node: AstNode } => - identifier.label !== undefined - ); + const value = [...identifiers.dataAccessors]; return ( {children} @@ -41,3 +27,35 @@ export function EditorIdentifiersProvider({ } export const useEditorIdentifiers = EditorIdentifiersContext.useValue; + +function coerceToConstant(search: string) { + const parsedNumber = Number(search); + const isNumber = !isNaN(parsedNumber); + + if (isNumber) { + return NewAstNode({ + constant: parsedNumber, + }); + } + + return NewAstNode({ + constant: search, + }); +} + +export function useGetIdentifierOptions() { + const identifiers = useEditorIdentifiers(); + const identifiersOptions = useMemo( + () => identifiers.map(adaptAstNodeToViewModel), + [identifiers] + ); + + return useCallback( + (search: string) => { + if (!search) return identifiersOptions; + const constantNode = coerceToConstant(search); + return [...identifiersOptions, adaptAstNodeToViewModel(constantNode)]; + }, + [identifiersOptions] + ); +} diff --git a/packages/app-builder/src/services/editor/index.ts b/packages/app-builder/src/services/editor/index.ts index f42b0bb8c..67b83f17c 100644 --- a/packages/app-builder/src/services/editor/index.ts +++ b/packages/app-builder/src/services/editor/index.ts @@ -1 +1,2 @@ +export * from './ast-expression'; export * from './identifiers'; diff --git a/packages/marble-api/scripts/openapi.yaml b/packages/marble-api/scripts/openapi.yaml index 47cccd036..7064ca9c5 100644 --- a/packages/marble-api/scripts/openapi.yaml +++ b/packages/marble-api/scripts/openapi.yaml @@ -1442,28 +1442,28 @@ paths: $ref: '#/components/responses/403' /ast-expression/save-rule: patch: - tags: - - Editor - summary: Save a rule - operationId: saveRule - security: - - bearerAuth: [] - requestBody: - description: The rule to save - content: - application/json: - schema: - $ref: '#/components/schemas/PatchRuleWithAstExpression' - required: true - responses: - '204': - description: The rule has been saved - '401': - $ref: '#/components/responses/401' - '403': - $ref: '#/components/responses/403' - '404': - $ref: '#/components/responses/404' + tags: + - Editor + summary: Save a rule + operationId: saveRule + security: + - bearerAuth: [] + requestBody: + description: The rule to save + content: + application/json: + schema: + $ref: '#/components/schemas/PatchRuleWithAstExpression' + required: true + responses: + '204': + description: The rule has been saved + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' components: schemas: From efe23bd36a5baa366fa513a8af031e42ca18af2d Mon Sep 17 00:00:00 2001 From: Thomas Lathuiliere Date: Fri, 21 Jul 2023 14:07:59 +0200 Subject: [PATCH 04/17] feat(operators): update mocked operators --- packages/app-builder/src/components/Edit/EditAstNode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-builder/src/components/Edit/EditAstNode.tsx b/packages/app-builder/src/components/Edit/EditAstNode.tsx index 8dd2fae60..278652d02 100644 --- a/packages/app-builder/src/components/Edit/EditAstNode.tsx +++ b/packages/app-builder/src/components/Edit/EditAstNode.tsx @@ -155,4 +155,4 @@ const EditOperator = forwardRef< }); EditOperator.displayName = 'EditOperator'; -const mockedOperators = ['='] as const; +const mockedOperators = ['*', '+', '-', '/', '<', '=', '>'] as const; From 90cf401f59b5dc8bfdef935b96427342a21b5c7f Mon Sep 17 00:00:00 2001 From: Thomas Lathuiliere Date: Fri, 21 Jul 2023 16:05:58 +0200 Subject: [PATCH 05/17] feat(operators): lift mock to the loader --- .../src/components/Edit/EditAstNode.tsx | 24 +++++----- .../i/$iterationId/edit.rules.$ruleId.tsx | 25 +++++++---- .../app-builder/src/services/editor/index.ts | 1 + .../src/services/editor/operators.tsx | 44 +++++++++++++++++++ 4 files changed, 72 insertions(+), 22 deletions(-) create mode 100644 packages/app-builder/src/services/editor/operators.tsx diff --git a/packages/app-builder/src/components/Edit/EditAstNode.tsx b/packages/app-builder/src/components/Edit/EditAstNode.tsx index 278652d02..4d01982d3 100644 --- a/packages/app-builder/src/components/Edit/EditAstNode.tsx +++ b/packages/app-builder/src/components/Edit/EditAstNode.tsx @@ -1,13 +1,14 @@ import { adaptAstNodeToViewModel, type AstNode } from '@app-builder/models'; import { + useEditorOperators, useGetIdentifierOptions, + useGetOperatorName, useIsEditedOnce, } from '@app-builder/services/editor'; import { Combobox, Select } from '@ui-design-system'; import { forwardRef, useState } from 'react'; import { FormControl, FormField, FormItem } from '../Form'; -//import { useGetOperatorLabel } from '../Scenario/Formula/Operators'; export function EditAstNode({ name }: { name: string }) { const isFirstChildEditedOnce = useIsEditedOnce(`${name}.children.0`); @@ -110,7 +111,8 @@ const EditOperator = forwardRef< onBlur: () => void; } >(({ name, value, onChange, onBlur }, ref) => { - // const getOperatorLabel = useGetOperatorLabel(); + const operators = useEditorOperators(); + const getOperatorName = useGetOperatorName(); return ( - {mockedOperators.map((operator) => { + {operators.map((operator) => { return ( -

- - - {/* {getOperatorLabel(operator)} */} - {operator} - - - {operator} -

+ + + {getOperatorName(operator)} + +
); })} @@ -154,5 +152,3 @@ const EditOperator = forwardRef< ); }); EditOperator.displayName = 'EditOperator'; - -const mockedOperators = ['*', '+', '-', '/', '<', '=', '>'] as const; diff --git a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx index e36564c7c..fddd5f418 100644 --- a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx +++ b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx @@ -8,7 +8,10 @@ import { EditAstNode, RootOrOperator } from '@app-builder/components/Edit'; import { setToastMessage } from '@app-builder/components/MarbleToaster'; import { Consequence } from '@app-builder/components/Scenario/Rule/Consequence'; import { type AstNode } from '@app-builder/models'; -import { EditorIdentifiersProvider } from '@app-builder/services/editor'; +import { + EditorIdentifiersProvider, + EditorOperatorsProvider, +} from '@app-builder/services/editor'; import { serverServices } from '@app-builder/services/init.server'; import { fromParams, fromUUID } from '@app-builder/utils/short-uuid'; import { DevTool } from '@hookform/devtools'; @@ -37,6 +40,9 @@ export async function loader({ request, params }: LoaderArgs) { ruleId, }); + //TODO: replace this mocked operators with real ones + const mockedOperators = ['*', '+', '-', '/', '<', '=', '>'] as const; + const identifiers = editor.listIdentifiers({ scenarioId, }); @@ -44,6 +50,7 @@ export async function loader({ request, params }: LoaderArgs) { return json({ rule: await scenarioIterationRule, identifiers: await identifiers, + operators: mockedOperators, }); } @@ -97,7 +104,7 @@ export async function action({ request, params }: ActionArgs) { export default function RuleView() { const { t } = useTranslation(handle.i18n); - const { rule, identifiers } = useLoaderData(); + const { rule, identifiers, operators } = useLoaderData(); const fetcher = useFetcher(); //@ts-expect-error recursive type is not supported @@ -134,14 +141,16 @@ export default function RuleView() { - - {/* + + {/* } /> */} - } - /> - + } + /> + + ); }); -AddLogicalOperator.displayName = 'AddLogicalOperator'; +AddLogicalOperatorButton.displayName = 'AddLogicalOperatorButton'; export function RootOrOperator({ renderAstNode, @@ -44,7 +44,7 @@ export function RootOrOperator({ function appendOrOperand() { append({ - name: 'AND', + name: 'And', children: [NewAstNode()], namedChildren: {}, constant: null, @@ -60,7 +60,7 @@ export function RootOrOperator({ {!isFirstOperand && (
- @@ -77,7 +77,7 @@ export function RootOrOperator({ ); })} - +
); } @@ -125,12 +125,12 @@ function RootAndOperator({
{renderAstNode({ name: `${name}.${operandIndex}` })}
- + ); })} - { return ( - @@ -56,7 +56,7 @@ export function Rule({ rule }: { rule: ScenarioIterationRule }) { )} {!isLastOperand && ( <> - diff --git a/packages/app-builder/src/repositories/ScenarioRepository.ts b/packages/app-builder/src/repositories/ScenarioRepository.ts index 286c26371..a07b497e3 100644 --- a/packages/app-builder/src/repositories/ScenarioRepository.ts +++ b/packages/app-builder/src/repositories/ScenarioRepository.ts @@ -4,11 +4,11 @@ import { adaptFormulaDto, type AstNode } from '@app-builder/models'; export type ScenarioRepository = ReturnType; export function isOrAndGroup(astNode: AstNode): boolean { - if (astNode.name !== 'OR') { + if (astNode.name !== 'Or') { return false; } for (const child of astNode.children) { - if (child.name !== 'AND') { + if (child.name !== 'And') { return false; } } @@ -17,11 +17,11 @@ export function isOrAndGroup(astNode: AstNode): boolean { export function wrapInOrAndGroups(astNode: AstNode): AstNode { return { - name: 'OR', + name: 'Or', constant: null, children: [ { - name: 'AND', + name: 'And', constant: null, children: [astNode], namedChildren: {}, diff --git a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit/trigger.tsx b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit/trigger.tsx index d3a0e1731..73f61bbc4 100644 --- a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit/trigger.tsx +++ b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit/trigger.tsx @@ -1,6 +1,6 @@ import { Callout, Paper } from '@app-builder/components'; import { Formula } from '@app-builder/components/Scenario/Formula'; -import { LogicalOperator } from '@app-builder/components/Scenario/LogicalOperator'; +import { LogicalOperatorLabel } from '@app-builder/components/Scenario/LogicalOperator'; import { ScenarioBox } from '@app-builder/components/Scenario/ScenarioBox'; import { type Operator } from '@marble-api'; import clsx from 'clsx'; @@ -147,7 +147,7 @@ function TriggerCondition({ )} />
- diff --git a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/view/trigger.tsx b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/view/trigger.tsx index 0bd1cc7d2..0f69f9df1 100644 --- a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/view/trigger.tsx +++ b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/view/trigger.tsx @@ -1,6 +1,6 @@ import { Callout, Paper } from '@app-builder/components'; import { Formula } from '@app-builder/components/Scenario/Formula'; -import { LogicalOperator } from '@app-builder/components/Scenario/LogicalOperator'; +import { LogicalOperatorLabel } from '@app-builder/components/Scenario/LogicalOperator'; import { ScenarioBox } from '@app-builder/components/Scenario/ScenarioBox'; import { type Operator } from '@marble-api'; import clsx from 'clsx'; @@ -177,7 +177,7 @@ function TriggerCondition() { )} />
- From 609493c7ea09adbcab471380d5f387a0635c325f Mon Sep 17 00:00:00 2001 From: Alexandre Ablon Date: Thu, 20 Jul 2023 18:06:37 +0200 Subject: [PATCH 10/17] Add edit builder save and load (incomplete) --- .../app-builder/public/locales/en/common.json | 3 +- .../app-builder/public/locales/fr/common.json | 5 +- .../src/components/Edit/EditAstNode.tsx | 62 +++---- .../src/components/Edit/RootOrWithAnd.tsx | 8 +- .../src/components/Edit/WildEditAstNode.tsx | 10 +- packages/app-builder/src/models/ast-node.ts | 15 +- .../app-builder/src/models/marble-session.ts | 19 +- .../src/repositories/EditorRepository.ts | 16 +- .../src/repositories/ScenarioRepository.ts | 24 ++- .../i/$iterationId/edit.rules.$ruleId.tsx | 171 ++++++++++-------- packages/marble-api/scripts/openapi.yaml | 39 ++++ .../marble-api/src/generated/marble-api.ts | 46 ++++- .../ui-design-system/src/Button/Button.tsx | 1 + 13 files changed, 267 insertions(+), 152 deletions(-) diff --git a/packages/app-builder/public/locales/en/common.json b/packages/app-builder/public/locales/en/common.json index ee968f277..7ebfc9ea7 100644 --- a/packages/app-builder/public/locales/en/common.json +++ b/packages/app-builder/public/locales/en/common.json @@ -10,5 +10,6 @@ "delete": "Delete", "close": "Close", "clipboard.copy": "Copied in clipboard: {{value}}", - "empty_scenario_iteration_list": "Reach out to Marble to create your first scenario draft." + "empty_scenario_iteration_list": "Reach out to Marble to create your first scenario draft.", + "success.save": "Saved successfully" } diff --git a/packages/app-builder/public/locales/fr/common.json b/packages/app-builder/public/locales/fr/common.json index f3e0d75e3..3bdd82f91 100644 --- a/packages/app-builder/public/locales/fr/common.json +++ b/packages/app-builder/public/locales/fr/common.json @@ -2,7 +2,7 @@ "auth.logout": "Se déconnecter", "auth.login": "Connexion", "cancel": "Annuler", - "clipboard.copy": "Copié dans le presse-papier : {{value}}", + "clipboard.copy": "Copié dans le presse-papier : {{value}}", "close": "Fermer", "errors.unknown": "Une erreur inconnue s'est produite", "errors.edit.forbidden_not_draft": "Vous ne pouvez modifier qu'une version brouillon d'un scénario.", @@ -10,5 +10,6 @@ "search": "Recherche", "empty_scenario_iteration_list": "Contactez Marble pour créer votre premier brouillon de scénario.", "delete": "Supprimer", - "save": "Sauvegarder" + "save": "Sauvegarder", + "success.save": "Sauvegarde réussie" } diff --git a/packages/app-builder/src/components/Edit/EditAstNode.tsx b/packages/app-builder/src/components/Edit/EditAstNode.tsx index 352b56da6..fd158d37a 100644 --- a/packages/app-builder/src/components/Edit/EditAstNode.tsx +++ b/packages/app-builder/src/components/Edit/EditAstNode.tsx @@ -2,15 +2,18 @@ import { type AstNode, NewAstNode } from '@app-builder/models'; import { useEditorIdentifiers } from '@app-builder/services/editor'; import { Combobox, Select } from '@ui-design-system'; import { forwardRef, useCallback, useState } from 'react'; -import { useFormContext } from 'react-hook-form'; +import { useWatch } from 'react-hook-form'; import { FormControl, FormField, FormItem } from '../Form'; -import { useGetOperatorLabel } from '../Scenario/Formula/Operators'; +//import { useGetOperatorLabel } from '../Scenario/Formula/Operators'; + +function isAstNodeEmpty(node: AstNode): boolean { + return !node.name && !node.constant && node.children?.length === 0 && Object.keys(node.namedChildren).length ===0; +} export function EditAstNode({ name }: { name: string }) { - const { getFieldState, formState } = useFormContext(); - const firstChildState = getFieldState(`${name}.children.0`, formState); - const nameState = getFieldState(`${name}.name`, formState); + const firstChildNode = useWatch(`${name}.children.0`); + const nameNode = useWatch(`${name}.name`); return (
@@ -26,9 +29,8 @@ export function EditAstNode({ name }: { name: string }) { /> ( - + @@ -38,7 +40,7 @@ export function EditAstNode({ name }: { name: string }) { ( - + @@ -48,31 +50,41 @@ export function EditAstNode({ name }: { name: string }) {
); } +function getSelectedItem({value}: {value: AstNode | null }){ + if(!value) return null + + if (!value.name && value.constant) { + return {label: value.constant.toString(), node: value} + } + return null +} //TODO: connect value to Combobox (we may need to save {label:string; node: AstNode} in the form to ease the process) const EditOperand = forwardRef< HTMLInputElement, { name: string; - value: string | null; + value: AstNode | null; onChange: (value: AstNode | null) => void; onBlur: () => void; } ->(({ onChange, onBlur }, ref) => { +>(({ onChange, onBlur, value }, ref) => { const getIdentifierOptions = useGetIdentifierOptions(); - const [inputValue, setInputValue] = useState(''); - const [selectedItem, setSelectedItem] = useState< - ReturnType[number] | null - >(null); + + const selectedItem = getSelectedItem({value}) + + const [inputValue, setInputValue] = useState(selectedItem?.label ?? ""); + const items = getIdentifierOptions(inputValue); const filteredItems = items.filter((item) => item.label.includes(inputValue)); + + items.find((item) => item.node === value) return ( { - setSelectedItem(value); onChange(value?.node ?? null); }} nullable @@ -143,7 +155,7 @@ const EditOperator = forwardRef< onBlur: () => void; } >(({ name, value, onChange, onBlur }, ref) => { - const getOperatorLabel = useGetOperatorLabel(); + // const getOperatorLabel = useGetOperatorLabel(); return ( - {getOperatorLabel(operator)} + {/* {getOperatorLabel(operator)} */} + {operator} {operator} @@ -188,18 +201,5 @@ const EditOperator = forwardRef< EditOperator.displayName = 'EditOperator'; const mockedOperators = [ - 'EQUAL_BOOL', - 'EQUAL_FLOAT', - 'EQUAL_STRING', - 'AND', - 'PRODUCT_FLOAT', - 'OR', - 'SUM_FLOAT', - 'SUBTRACT_FLOAT', - 'DIVIDE_FLOAT', - 'GREATER_FLOAT', - 'GREATER_OR_EQUAL_FLOAT', - 'LESSER_FLOAT', - 'LESSER_OR_EQUAL_FLOAT', - 'STRING_IS_IN_LIST', + '=', ] as const; diff --git a/packages/app-builder/src/components/Edit/RootOrWithAnd.tsx b/packages/app-builder/src/components/Edit/RootOrWithAnd.tsx index 770fd340b..391dfccdb 100644 --- a/packages/app-builder/src/components/Edit/RootOrWithAnd.tsx +++ b/packages/app-builder/src/components/Edit/RootOrWithAnd.tsx @@ -29,6 +29,10 @@ const AddLogicalOperatorButton = React.forwardRef< }); AddLogicalOperatorButton.displayName = 'AddLogicalOperatorButton'; +function NewBinaryAstNode(){ + return NewAstNode({children: [NewAstNode(),NewAstNode()]} ) +} + export function RootOrOperator({ renderAstNode, }: { @@ -45,7 +49,7 @@ export function RootOrOperator({ function appendOrOperand() { append({ name: 'And', - children: [NewAstNode()], + children: [NewBinaryAstNode()], namedChildren: {}, constant: null, }); @@ -105,7 +109,7 @@ function RootAndOperator({ } function appendAndOperand() { - append(NewAstNode()); + append(NewBinaryAstNode()); } return ( diff --git a/packages/app-builder/src/components/Edit/WildEditAstNode.tsx b/packages/app-builder/src/components/Edit/WildEditAstNode.tsx index 78760efcd..a71633410 100644 --- a/packages/app-builder/src/components/Edit/WildEditAstNode.tsx +++ b/packages/app-builder/src/components/Edit/WildEditAstNode.tsx @@ -1,4 +1,4 @@ -import { type AstNode, NewAstNode, NoConstant } from '@app-builder/models'; +import { type AstNode, NewAstNode } from '@app-builder/models'; import { Button, Input } from '@ui-design-system'; import { useFieldArray } from 'react-hook-form'; @@ -42,13 +42,9 @@ export function WildEditAstNode({ { - field.onChange(event.target.value || NoConstant); + field.onChange(event.target.value ?? null); }} /> diff --git a/packages/app-builder/src/models/ast-node.ts b/packages/app-builder/src/models/ast-node.ts index b03b37377..74bb11ae7 100644 --- a/packages/app-builder/src/models/ast-node.ts +++ b/packages/app-builder/src/models/ast-node.ts @@ -11,7 +11,7 @@ import { export interface AstNode { name: string | null; - constant: ConstantOptional; + constant: ConstantType | null; children: AstNode[]; namedChildren: Record; } @@ -24,8 +24,6 @@ export type ConstantType = | Array | { [key: string]: ConstantType }; -export const NoConstant: unique symbol = Symbol(); -export type ConstantOptional = ConstantType | typeof NoConstant; // helper export function NewAstNode({ @@ -36,7 +34,7 @@ export function NewAstNode({ }: Partial = {}): AstNode { return { name: name ?? null, - constant: constant ?? NoConstant, + constant: constant ?? null, children: children ?? [], namedChildren: namedChildren ?? {}, }; @@ -121,3 +119,12 @@ export function adaptNodeDto(nodeDto: NodeDto): AstNode { namedChildren: R.mapValues(nodeDto.named_children ?? {}, adaptNodeDto), }); } + +export function adaptAstNode(astNode: AstNode): NodeDto { + return { + name: astNode.name ?? undefined, + constant: astNode.constant ?? undefined, + children: astNode.children?.map(adaptAstNode), + named_children: R.mapValues(astNode.namedChildren ?? {}, adaptAstNode), + }; +} diff --git a/packages/app-builder/src/models/marble-session.ts b/packages/app-builder/src/models/marble-session.ts index e51a8a8f7..e04ef0eea 100644 --- a/packages/app-builder/src/models/marble-session.ts +++ b/packages/app-builder/src/models/marble-session.ts @@ -1,16 +1,17 @@ -import { type Token } from '@marble-api'; -import { type Session } from '@remix-run/node'; -import * as z from 'zod'; +import { type Token } from "@marble-api"; +import { type Session } from "@remix-run/node"; +import * as z from "zod"; -import { type AuthErrors } from './auth-errors'; +import { type AuthErrors } from "./auth-errors"; export const toastMessageScema = z.object({ - type: z.enum(['success', 'error', 'loading', 'custom']), + type: z.enum(["success", "error", "loading", "custom"]), messageKey: z.enum([ - 'common:errors.unknown', - 'common:empty_scenario_iteration_list', - 'common:errors.edit.forbidden_not_draft', - 'common:errors.list.duplicate_list_name', + "common:errors.unknown", + "common:empty_scenario_iteration_list", + "common:errors.edit.forbidden_not_draft", + "common:errors.list.duplicate_list_name", + "common:success.save", ]), }); diff --git a/packages/app-builder/src/repositories/EditorRepository.ts b/packages/app-builder/src/repositories/EditorRepository.ts index b6144e304..30b2823d1 100644 --- a/packages/app-builder/src/repositories/EditorRepository.ts +++ b/packages/app-builder/src/repositories/EditorRepository.ts @@ -1,5 +1,5 @@ -import { type MarbleApi } from '@app-builder/infra/marble-api'; -import { adaptNodeDto } from '@app-builder/models'; +import { type MarbleApi } from "@app-builder/infra/marble-api"; +import { adaptAstNode,adaptNodeDto, type AstNode } from "@app-builder/models"; export type EditorRepository = ReturnType; @@ -14,5 +14,17 @@ export function getEditorRepository() { return { dataAccessors }; }, + saveRule: async ({ + ruleId, + astNode, + }: { + ruleId: string; + astNode: AstNode; + }) => { + return marbleApiClient.saveRule({ + rule_id: ruleId, + expression: adaptAstNode(astNode), + }); + }, }); } diff --git a/packages/app-builder/src/repositories/ScenarioRepository.ts b/packages/app-builder/src/repositories/ScenarioRepository.ts index a07b497e3..201f443ef 100644 --- a/packages/app-builder/src/repositories/ScenarioRepository.ts +++ b/packages/app-builder/src/repositories/ScenarioRepository.ts @@ -1,29 +1,29 @@ -import { type MarbleApi } from '@app-builder/infra/marble-api'; -import { adaptFormulaDto, type AstNode } from '@app-builder/models'; +import { type MarbleApi } from "@app-builder/infra/marble-api"; +import { adaptNodeDto, type AstNode } from "@app-builder/models"; export type ScenarioRepository = ReturnType; export function isOrAndGroup(astNode: AstNode): boolean { - if (astNode.name !== 'Or') { + if (astNode.name !== "Or") { return false; } for (const child of astNode.children) { - if (child.name !== 'And') { + if (child.name !== "And") { return false; } } return true; } -export function wrapInOrAndGroups(astNode: AstNode): AstNode { +export function wrapInOrAndGroups(astNode?: AstNode): AstNode { return { - name: 'Or', + name: "Or", constant: null, children: [ { - name: 'And', + name: "And", constant: null, - children: [astNode], + children: astNode ? [astNode] : [], namedChildren: {}, }, ], @@ -34,10 +34,14 @@ export function wrapInOrAndGroups(astNode: AstNode): AstNode { export function getScenarioRepository() { return (marbleApiClient: MarbleApi) => ({ getScenarioIterationRule: async ({ ruleId }: { ruleId: string }) => { - const { formula, ...rule } = + const { formula_ast_expression, ...rule } = await marbleApiClient.getScenarioIterationRule(ruleId); - const astNode = adaptFormulaDto(formula); + if (!formula_ast_expression) { + return { ...rule, astNode: wrapInOrAndGroups() }; + } + + const astNode = adaptNodeDto(formula_ast_expression); const orAndGroupAstNode = isOrAndGroup(astNode) ? astNode diff --git a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx index 033bde2ae..2486514e1 100644 --- a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx +++ b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx @@ -3,34 +3,35 @@ import { Paper, scenarioI18n, ScenarioPage, -} from '@app-builder/components'; -import { EditAstNode, RootOrOperator } from '@app-builder/components/Edit'; -import { Consequence } from '@app-builder/components/Scenario/Rule/Consequence'; -import { type AstNode } from '@app-builder/models'; -import { EditorIdentifiersProvider } from '@app-builder/services/editor'; -import { serverServices } from '@app-builder/services/init.server'; -import { fromParams, fromUUID } from '@app-builder/utils/short-uuid'; -import { DevTool } from '@hookform/devtools'; -import { json, type LoaderArgs } from '@remix-run/node'; -import { Link, useLoaderData } from '@remix-run/react'; -import { Button, Tag } from '@ui-design-system'; -import { type Namespace } from 'i18next'; -import { FormProvider, useForm } from 'react-hook-form'; -import toast from 'react-hot-toast'; -import { ClientOnly } from 'remix-utils'; +} from "@app-builder/components"; +import { EditAstNode, RootOrOperator } from "@app-builder/components/Edit"; +import { setToastMessage } from "@app-builder/components/MarbleToaster"; +import { Consequence } from "@app-builder/components/Scenario/Rule/Consequence"; +import { type AstNode } from "@app-builder/models"; +import { EditorIdentifiersProvider } from "@app-builder/services/editor"; +import { serverServices } from "@app-builder/services/init.server"; +import { fromParams, fromUUID } from "@app-builder/utils/short-uuid"; +import { DevTool } from "@hookform/devtools"; +import { type ActionArgs, json, type LoaderArgs } from "@remix-run/node"; +import { Link, useFetcher, useLoaderData } from "@remix-run/react"; +import { Button, Tag } from "@ui-design-system"; +import { type Namespace } from "i18next"; +import { Form, FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { ClientOnly } from "remix-utils"; export const handle = { - i18n: [...scenarioI18n] satisfies Namespace, + i18n: [...scenarioI18n, "common"] satisfies Namespace, }; export async function loader({ request, params }: LoaderArgs) { const { authService } = serverServices; const { editor, scenario } = await authService.isAuthenticated(request, { - failureRedirect: '/login', + failureRedirect: "/login", }); - const ruleId = fromParams(params, 'ruleId'); - const scenarioId = fromParams(params, 'scenarioId'); + const ruleId = fromParams(params, "ruleId"); + const scenarioId = fromParams(params, "scenarioId"); const scenarioIterationRule = scenario.getScenarioIterationRule({ ruleId, @@ -46,14 +47,59 @@ export async function loader({ request, params }: LoaderArgs) { }); } +export async function action({ request, params }: ActionArgs) { + const { authService,sessionService: { getSession, commitSession }} = serverServices; + const session = await getSession(request); + const { editor } = await authService.isAuthenticated(request, { + failureRedirect: "/login", + }); + + try { + const ruleId = fromParams(params, "ruleId"); + + const expression = (await request.json()) as FormValues; + + await editor.saveRule({ ruleId, astNode: expression.astNode }); + + setToastMessage(session, { + type: "success", + messageKey: "common:success.save", + }); + return json({ + success: true as const, + error: null, + values: expression, + }, + { headers: { "Set-Cookie": await commitSession(session) } }); + } catch (error) { + setToastMessage(session, { + type: "error", + messageKey: "common:errors.unknown", + }); + + return json( + { + success: false as const, + error: null, + values: null, + }, + { headers: { "Set-Cookie": await commitSession(session) } } + ); + } +} + +interface FormValues { + astNode: AstNode; +} + export default function RuleView() { + const { t } = useTranslation(handle.i18n); const { rule, identifiers } = useLoaderData(); - - const formMethods = useForm<{ astNode: AstNode }>({ + console.log("view ast", JSON.stringify(rule.astNode, null, 2)); + const fetcher = useFetcher(); + const formMethods = useForm({ // TODO(builder): defaultValues is not working - // defaultValues: { - // astNode: rule.astNode, - // }, + defaultValues: { astNode: rule.astNode as AstNode }, }); return ( @@ -72,64 +118,41 @@ export default function RuleView() { {rule.description} -
- - - - - {/* { + fetcher.submit(data, { + method: "PATCH", + encType: "application/json", + }); + }} + > +
+ + + + + {/* } /> */} - } - /> - - - - -
+ } + /> +
+
+
+ +
+ {() => ( )} diff --git a/packages/marble-api/scripts/openapi.yaml b/packages/marble-api/scripts/openapi.yaml index 974134570..47cccd036 100644 --- a/packages/marble-api/scripts/openapi.yaml +++ b/packages/marble-api/scripts/openapi.yaml @@ -1440,6 +1440,31 @@ paths: $ref: '#/components/responses/401' '403': $ref: '#/components/responses/403' + /ast-expression/save-rule: + patch: + tags: + - Editor + summary: Save a rule + operationId: saveRule + security: + - bearerAuth: [] + requestBody: + description: The rule to save + content: + application/json: + schema: + $ref: '#/components/schemas/PatchRuleWithAstExpression' + required: true + responses: + '204': + description: The rule has been saved + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + components: schemas: Organization: @@ -1932,6 +1957,9 @@ components: formula: $ref: '#/components/schemas/Operator' description: Valid marshalled operator + formula_ast_expression: + $ref: '#/components/schemas/NodeDto' + description: Valid marshalled ast_node scoreModifier: type: integer createdAt: @@ -2464,6 +2492,17 @@ components: properties: level: type: integer + PatchRuleWithAstExpression: + type: object + required: + - rule_id + - expression + properties: + rule_id: + type: string + format: uuid + expression: + $ref: '#/components/schemas/NodeDto' securitySchemes: bearerAuth: type: http diff --git a/packages/marble-api/src/generated/marble-api.ts b/packages/marble-api/src/generated/marble-api.ts index 94d38bac5..0e546b491 100644 --- a/packages/marble-api/src/generated/marble-api.ts +++ b/packages/marble-api/src/generated/marble-api.ts @@ -263,6 +263,17 @@ export type CreateScenarioIterationBody = { rules?: CreateScenarioIterationRuleBody[]; }; }; +export type ConstantDto = (string | number | boolean | ConstantDto[] | { + [key: string]: ConstantDto; +}) | null; +export type NodeDto = { + name?: string; + constant?: ConstantDto; + children?: NodeDto[]; + named_children?: { + [key: string]: NodeDto; + }; +}; export type ScenarioIterationRule = { id: string; scenarioIterationId: string; @@ -270,6 +281,7 @@ export type ScenarioIterationRule = { name: string; description: string; formula: Operator; + formula_ast_expression?: NodeDto; scoreModifier: number; createdAt: string; }; @@ -357,16 +369,9 @@ export type UpdateOrganizationBodyDto = { name?: string; database_name?: string; }; -export type ConstantDto = (string | number | boolean | ConstantDto[] | { - [key: string]: ConstantDto; -}) | null; -export type NodeDto = { - name?: string; - constant?: ConstantDto; - children?: NodeDto[]; - named_children?: { - [key: string]: NodeDto; - }; +export type PatchRuleWithAstExpression = { + rule_id: string; + expression: NodeDto; }; /** * Get an access token @@ -1341,3 +1346,24 @@ export function listIdentifiers(scenarioId: string, opts?: Oazapfts.RequestOpts) ...opts })); } +/** + * Save a rule + */ +export function saveRule(patchRuleWithAstExpression: PatchRuleWithAstExpression, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 204; + } | { + status: 401; + data: string; + } | { + status: 403; + data: string; + } | { + status: 404; + data: string; + }>("/ast-expression/save-rule", oazapfts.json({ + ...opts, + method: "PATCH", + body: patchRuleWithAstExpression + }))); +} diff --git a/packages/ui-design-system/src/Button/Button.tsx b/packages/ui-design-system/src/Button/Button.tsx index d304a61e5..cf0fbc3cf 100644 --- a/packages/ui-design-system/src/Button/Button.tsx +++ b/packages/ui-design-system/src/Button/Button.tsx @@ -40,6 +40,7 @@ export const Button = forwardRef( return (
); })} diff --git a/packages/app-builder/src/components/Edit/WildEditAstNode.tsx b/packages/app-builder/src/components/Edit/WildEditAstNode.tsx index a71633410..143e0ed80 100644 --- a/packages/app-builder/src/components/Edit/WildEditAstNode.tsx +++ b/packages/app-builder/src/components/Edit/WildEditAstNode.tsx @@ -42,7 +42,7 @@ export function WildEditAstNode({ { field.onChange(event.target.value ?? null); }} diff --git a/packages/app-builder/src/components/Scenario/LogicalOperator.tsx b/packages/app-builder/src/components/Scenario/LogicalOperator.tsx index 5ace6242b..af738d7df 100644 --- a/packages/app-builder/src/components/Scenario/LogicalOperator.tsx +++ b/packages/app-builder/src/components/Scenario/LogicalOperator.tsx @@ -8,7 +8,10 @@ interface LogicalOperatorLabelProps { className?: string; } -export function LogicalOperatorLabel({ operator, className }: LogicalOperatorLabelProps) { +export function LogicalOperatorLabel({ + operator, + className, +}: LogicalOperatorLabelProps) { const { t } = useTranslation(scenarioI18n); return ( diff --git a/packages/app-builder/src/models/ast-node.ts b/packages/app-builder/src/models/ast-node.ts index 74bb11ae7..0d03dd394 100644 --- a/packages/app-builder/src/models/ast-node.ts +++ b/packages/app-builder/src/models/ast-node.ts @@ -24,7 +24,6 @@ export type ConstantType = | Array | { [key: string]: ConstantType }; - // helper export function NewAstNode({ name, @@ -128,3 +127,37 @@ export function adaptAstNode(astNode: AstNode): NodeDto { named_children: R.mapValues(astNode.namedChildren ?? {}, adaptAstNode), }; } + +export function isAstNodeEmpty(node: AstNode): boolean { + return ( + !node.name && + !node.constant && + node.children?.length === 0 && + Object.keys(node.namedChildren).length === 0 + ); +} + +export interface ConstantAstNode { + name: null; + constant: T; + children: []; + namedChildren: Record; +} + +export function isConstant(node: AstNode): node is ConstantAstNode { + return !node.name && !!node.constant; +} + +export interface DatabaseAccessAstNode { + name: 'DatabaseAccess'; + constant: null; + children: []; + namedChildren: { + path: ConstantAstNode; + fieldName: ConstantAstNode; + }; +} + +export function isDatabaseAccess(node: AstNode): node is DatabaseAccessAstNode { + return node.name === 'DatabaseAccess'; +} diff --git a/packages/app-builder/src/models/ast-view-model.ts b/packages/app-builder/src/models/ast-view-model.ts new file mode 100644 index 000000000..e29c1384a --- /dev/null +++ b/packages/app-builder/src/models/ast-view-model.ts @@ -0,0 +1,61 @@ +import { + type AstNode, + type ConstantType, + isAstNodeEmpty, + isConstant, + isDatabaseAccess, +} from './ast-node'; + +export interface AstViewModel { + label: string; + astNode: AstNode; +} + +export function adaptAstNodeToViewModel(astNode: AstNode): AstViewModel { + return { + label: getAstNodeDisplayName(astNode), + astNode, + }; +} + +export function adaptAstViewModelToAstNode( + astViewModel: AstViewModel +): AstNode { + return astViewModel.astNode; +} + +function getConstantDisplayName(constant: ConstantType) { + if (constant === null) return ''; + + if (typeof constant === 'string') { + return `"${constant}"`; + } + + if (typeof constant === 'number') { + return constant.toString(); + } + + // Handle other cases when needed + return constant.toString(); +} + +function getAstNodeDisplayName(astNode: AstNode) { + if (isConstant(astNode)) { + return getConstantDisplayName(astNode.constant); + } + + if (isDatabaseAccess(astNode)) { + const { path, fieldName } = astNode.namedChildren; + return [...path.constant, fieldName.constant].join('.'); + } + + if (isAstNodeEmpty(astNode)) { + return ''; + } + + // eslint-disable-next-line no-restricted-properties + if (process.env.NODE_ENV === 'development') { + console.warn('Unhandled astNode', astNode); + } + return ''; +} diff --git a/packages/app-builder/src/models/index.ts b/packages/app-builder/src/models/index.ts index 6f4d45b60..852b7c456 100644 --- a/packages/app-builder/src/models/index.ts +++ b/packages/app-builder/src/models/index.ts @@ -1,4 +1,5 @@ export * from './ast-node'; +export * from './ast-view-model'; export * from './auth-errors'; export * from './http-errors'; export * from './marble-session'; diff --git a/packages/app-builder/src/models/marble-session.ts b/packages/app-builder/src/models/marble-session.ts index e04ef0eea..91d07a0c2 100644 --- a/packages/app-builder/src/models/marble-session.ts +++ b/packages/app-builder/src/models/marble-session.ts @@ -1,17 +1,17 @@ -import { type Token } from "@marble-api"; -import { type Session } from "@remix-run/node"; -import * as z from "zod"; +import { type Token } from '@marble-api'; +import { type Session } from '@remix-run/node'; +import * as z from 'zod'; -import { type AuthErrors } from "./auth-errors"; +import { type AuthErrors } from './auth-errors'; export const toastMessageScema = z.object({ - type: z.enum(["success", "error", "loading", "custom"]), + type: z.enum(['success', 'error', 'loading', 'custom']), messageKey: z.enum([ - "common:errors.unknown", - "common:empty_scenario_iteration_list", - "common:errors.edit.forbidden_not_draft", - "common:errors.list.duplicate_list_name", - "common:success.save", + 'common:errors.unknown', + 'common:empty_scenario_iteration_list', + 'common:errors.edit.forbidden_not_draft', + 'common:errors.list.duplicate_list_name', + 'common:success.save', ]), }); diff --git a/packages/app-builder/src/repositories/EditorRepository.ts b/packages/app-builder/src/repositories/EditorRepository.ts index 30b2823d1..cd12c7b0c 100644 --- a/packages/app-builder/src/repositories/EditorRepository.ts +++ b/packages/app-builder/src/repositories/EditorRepository.ts @@ -1,5 +1,5 @@ -import { type MarbleApi } from "@app-builder/infra/marble-api"; -import { adaptAstNode,adaptNodeDto, type AstNode } from "@app-builder/models"; +import { type MarbleApi } from '@app-builder/infra/marble-api'; +import { adaptAstNode, adaptNodeDto, type AstNode } from '@app-builder/models'; export type EditorRepository = ReturnType; diff --git a/packages/app-builder/src/repositories/ScenarioRepository.ts b/packages/app-builder/src/repositories/ScenarioRepository.ts index 201f443ef..66612ac11 100644 --- a/packages/app-builder/src/repositories/ScenarioRepository.ts +++ b/packages/app-builder/src/repositories/ScenarioRepository.ts @@ -1,14 +1,14 @@ -import { type MarbleApi } from "@app-builder/infra/marble-api"; -import { adaptNodeDto, type AstNode } from "@app-builder/models"; +import { type MarbleApi } from '@app-builder/infra/marble-api'; +import { adaptNodeDto, type AstNode } from '@app-builder/models'; export type ScenarioRepository = ReturnType; export function isOrAndGroup(astNode: AstNode): boolean { - if (astNode.name !== "Or") { + if (astNode.name !== 'Or') { return false; } for (const child of astNode.children) { - if (child.name !== "And") { + if (child.name !== 'And') { return false; } } @@ -17,11 +17,11 @@ export function isOrAndGroup(astNode: AstNode): boolean { export function wrapInOrAndGroups(astNode?: AstNode): AstNode { return { - name: "Or", + name: 'Or', constant: null, children: [ { - name: "And", + name: 'And', constant: null, children: astNode ? [astNode] : [], namedChildren: {}, diff --git a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx index 2486514e1..e36564c7c 100644 --- a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx +++ b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx @@ -3,35 +3,35 @@ import { Paper, scenarioI18n, ScenarioPage, -} from "@app-builder/components"; -import { EditAstNode, RootOrOperator } from "@app-builder/components/Edit"; -import { setToastMessage } from "@app-builder/components/MarbleToaster"; -import { Consequence } from "@app-builder/components/Scenario/Rule/Consequence"; -import { type AstNode } from "@app-builder/models"; -import { EditorIdentifiersProvider } from "@app-builder/services/editor"; -import { serverServices } from "@app-builder/services/init.server"; -import { fromParams, fromUUID } from "@app-builder/utils/short-uuid"; -import { DevTool } from "@hookform/devtools"; -import { type ActionArgs, json, type LoaderArgs } from "@remix-run/node"; -import { Link, useFetcher, useLoaderData } from "@remix-run/react"; -import { Button, Tag } from "@ui-design-system"; -import { type Namespace } from "i18next"; -import { Form, FormProvider, useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; -import { ClientOnly } from "remix-utils"; +} from '@app-builder/components'; +import { EditAstNode, RootOrOperator } from '@app-builder/components/Edit'; +import { setToastMessage } from '@app-builder/components/MarbleToaster'; +import { Consequence } from '@app-builder/components/Scenario/Rule/Consequence'; +import { type AstNode } from '@app-builder/models'; +import { EditorIdentifiersProvider } from '@app-builder/services/editor'; +import { serverServices } from '@app-builder/services/init.server'; +import { fromParams, fromUUID } from '@app-builder/utils/short-uuid'; +import { DevTool } from '@hookform/devtools'; +import { type ActionArgs, json, type LoaderArgs } from '@remix-run/node'; +import { Link, useFetcher, useLoaderData } from '@remix-run/react'; +import { Button, Tag } from '@ui-design-system'; +import { type Namespace } from 'i18next'; +import { Form, FormProvider, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { ClientOnly } from 'remix-utils'; export const handle = { - i18n: [...scenarioI18n, "common"] satisfies Namespace, + i18n: [...scenarioI18n, 'common'] satisfies Namespace, }; export async function loader({ request, params }: LoaderArgs) { const { authService } = serverServices; const { editor, scenario } = await authService.isAuthenticated(request, { - failureRedirect: "/login", + failureRedirect: '/login', }); - const ruleId = fromParams(params, "ruleId"); - const scenarioId = fromParams(params, "scenarioId"); + const ruleId = fromParams(params, 'ruleId'); + const scenarioId = fromParams(params, 'scenarioId'); const scenarioIterationRule = scenario.getScenarioIterationRule({ ruleId, @@ -48,33 +48,40 @@ export async function loader({ request, params }: LoaderArgs) { } export async function action({ request, params }: ActionArgs) { - const { authService,sessionService: { getSession, commitSession }} = serverServices; + const { + authService, + sessionService: { getSession, commitSession }, + } = serverServices; const session = await getSession(request); const { editor } = await authService.isAuthenticated(request, { - failureRedirect: "/login", + failureRedirect: '/login', }); try { - const ruleId = fromParams(params, "ruleId"); + const ruleId = fromParams(params, 'ruleId'); - const expression = (await request.json()) as FormValues; + const expression = (await request.json()) as { + astNode: AstNode; + }; await editor.saveRule({ ruleId, astNode: expression.astNode }); setToastMessage(session, { - type: "success", - messageKey: "common:success.save", + type: 'success', + messageKey: 'common:success.save', }); - return json({ - success: true as const, - error: null, - values: expression, - }, - { headers: { "Set-Cookie": await commitSession(session) } }); + return json( + { + success: true as const, + error: null, + values: expression, + }, + { headers: { 'Set-Cookie': await commitSession(session) } } + ); } catch (error) { setToastMessage(session, { - type: "error", - messageKey: "common:errors.unknown", + type: 'error', + messageKey: 'common:errors.unknown', }); return json( @@ -83,23 +90,19 @@ export async function action({ request, params }: ActionArgs) { error: null, values: null, }, - { headers: { "Set-Cookie": await commitSession(session) } } + { headers: { 'Set-Cookie': await commitSession(session) } } ); } } -interface FormValues { - astNode: AstNode; -} - export default function RuleView() { const { t } = useTranslation(handle.i18n); const { rule, identifiers } = useLoaderData(); - console.log("view ast", JSON.stringify(rule.astNode, null, 2)); + const fetcher = useFetcher(); - const formMethods = useForm({ - // TODO(builder): defaultValues is not working - defaultValues: { astNode: rule.astNode as AstNode }, + //@ts-expect-error recursive type is not supported + const formMethods = useForm({ + defaultValues: { astNode: rule.astNode }, }); return ( @@ -122,8 +125,8 @@ export default function RuleView() { control={formMethods.control} onSubmit={({ data }) => { fetcher.submit(data, { - method: "PATCH", - encType: "application/json", + method: 'PATCH', + encType: 'application/json', }); }} > @@ -142,7 +145,7 @@ export default function RuleView() {
@@ -152,7 +155,7 @@ export default function RuleView() { control={formMethods.control} placement="bottom-right" styles={{ - panel: { width: "450px" }, + panel: { width: '450px' }, }} /> )} diff --git a/packages/app-builder/src/services/editor/ast-expression.tsx b/packages/app-builder/src/services/editor/ast-expression.tsx new file mode 100644 index 000000000..f6248f631 --- /dev/null +++ b/packages/app-builder/src/services/editor/ast-expression.tsx @@ -0,0 +1,38 @@ +import { type AstNode, isAstNodeEmpty } from '@app-builder/models'; +import { useEffect, useState } from 'react'; +import { useWatch } from 'react-hook-form'; + +function useWatchAstNode< + TName extends `${string}.children.${number}` | `${string}.name` +>( + name: TName +): TName extends `${string}.name` + ? AstNode['name'] + : AstNode['children'][number] { + return useWatch({ name }); +} + +function isAstNodeFieldEmpty(field: ReturnType) { + if (field === null) return true; + + if (typeof field === 'string') return !field; + + return isAstNodeEmpty(field); +} + +/** + * Used to determine if the field has been edited once. + * It is initialized with the value of the field and should be updated when the field is edited. + */ +export function useIsEditedOnce( + name: `${string}.children.${number}` | `${string}.name` +) { + const field = useWatchAstNode(name); + const [isEditedOnce, setIsEditedOnce] = useState(!isAstNodeFieldEmpty(field)); + useEffect(() => { + if (!isEditedOnce && !isAstNodeFieldEmpty(field)) { + setIsEditedOnce(true); + } + }, [field, isEditedOnce]); + return isEditedOnce; +} diff --git a/packages/app-builder/src/services/editor/identifiers.tsx b/packages/app-builder/src/services/editor/identifiers.tsx index e95cda30f..46ad7a004 100644 --- a/packages/app-builder/src/services/editor/identifiers.tsx +++ b/packages/app-builder/src/services/editor/identifiers.tsx @@ -1,19 +1,13 @@ -import { type AstNode } from '@app-builder/models'; +import { + adaptAstNodeToViewModel, + type AstNode, + NewAstNode, +} from '@app-builder/models'; import { createSimpleContext } from '@app-builder/utils/create-context'; - -function getIdentifierDisplayName(identifiers: AstNode) { - switch (identifiers.name) { - case 'DatabaseAccess': { - const { path, fieldName } = identifiers.namedChildren; - return [...(path.constant as string[]), fieldName.constant].join('.'); - } - default: - return undefined; - } -} +import { useCallback, useMemo } from 'react'; const EditorIdentifiersContext = - createSimpleContext<{ label: string; node: AstNode }[]>('EditorIdentifiers'); + createSimpleContext('EditorIdentifiers'); export function EditorIdentifiersProvider({ children, @@ -24,15 +18,7 @@ export function EditorIdentifiersProvider({ dataAccessors: AstNode[]; }; }) { - const value = identifiers.dataAccessors - .map((dataAccessor) => ({ - label: getIdentifierDisplayName(dataAccessor), - node: dataAccessor, - })) - .filter( - (identifier): identifier is { label: string; node: AstNode } => - identifier.label !== undefined - ); + const value = [...identifiers.dataAccessors]; return ( {children} @@ -41,3 +27,35 @@ export function EditorIdentifiersProvider({ } export const useEditorIdentifiers = EditorIdentifiersContext.useValue; + +function coerceToConstant(search: string) { + const parsedNumber = Number(search); + const isNumber = !isNaN(parsedNumber); + + if (isNumber) { + return NewAstNode({ + constant: parsedNumber, + }); + } + + return NewAstNode({ + constant: search, + }); +} + +export function useGetIdentifierOptions() { + const identifiers = useEditorIdentifiers(); + const identifiersOptions = useMemo( + () => identifiers.map(adaptAstNodeToViewModel), + [identifiers] + ); + + return useCallback( + (search: string) => { + if (!search) return identifiersOptions; + const constantNode = coerceToConstant(search); + return [...identifiersOptions, adaptAstNodeToViewModel(constantNode)]; + }, + [identifiersOptions] + ); +} diff --git a/packages/app-builder/src/services/editor/index.ts b/packages/app-builder/src/services/editor/index.ts index f42b0bb8c..67b83f17c 100644 --- a/packages/app-builder/src/services/editor/index.ts +++ b/packages/app-builder/src/services/editor/index.ts @@ -1 +1,2 @@ +export * from './ast-expression'; export * from './identifiers'; diff --git a/packages/marble-api/scripts/openapi.yaml b/packages/marble-api/scripts/openapi.yaml index 47cccd036..7064ca9c5 100644 --- a/packages/marble-api/scripts/openapi.yaml +++ b/packages/marble-api/scripts/openapi.yaml @@ -1442,28 +1442,28 @@ paths: $ref: '#/components/responses/403' /ast-expression/save-rule: patch: - tags: - - Editor - summary: Save a rule - operationId: saveRule - security: - - bearerAuth: [] - requestBody: - description: The rule to save - content: - application/json: - schema: - $ref: '#/components/schemas/PatchRuleWithAstExpression' - required: true - responses: - '204': - description: The rule has been saved - '401': - $ref: '#/components/responses/401' - '403': - $ref: '#/components/responses/403' - '404': - $ref: '#/components/responses/404' + tags: + - Editor + summary: Save a rule + operationId: saveRule + security: + - bearerAuth: [] + requestBody: + description: The rule to save + content: + application/json: + schema: + $ref: '#/components/schemas/PatchRuleWithAstExpression' + required: true + responses: + '204': + description: The rule has been saved + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' components: schemas: From f117b7f68ef0084e7eed239650112c512e98e0ea Mon Sep 17 00:00:00 2001 From: Thomas Lathuiliere Date: Fri, 21 Jul 2023 14:07:59 +0200 Subject: [PATCH 12/17] feat(operators): update mocked operators --- packages/app-builder/src/components/Edit/EditAstNode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-builder/src/components/Edit/EditAstNode.tsx b/packages/app-builder/src/components/Edit/EditAstNode.tsx index 8dd2fae60..278652d02 100644 --- a/packages/app-builder/src/components/Edit/EditAstNode.tsx +++ b/packages/app-builder/src/components/Edit/EditAstNode.tsx @@ -155,4 +155,4 @@ const EditOperator = forwardRef< }); EditOperator.displayName = 'EditOperator'; -const mockedOperators = ['='] as const; +const mockedOperators = ['*', '+', '-', '/', '<', '=', '>'] as const; From 160614e67248699f0c58e0ca76474a3fa3b86d93 Mon Sep 17 00:00:00 2001 From: Thomas Lathuiliere Date: Fri, 21 Jul 2023 16:05:58 +0200 Subject: [PATCH 13/17] feat(operators): lift mock to the loader --- .../src/components/Edit/EditAstNode.tsx | 24 +++++----- .../i/$iterationId/edit.rules.$ruleId.tsx | 25 +++++++---- .../app-builder/src/services/editor/index.ts | 1 + .../src/services/editor/operators.tsx | 44 +++++++++++++++++++ 4 files changed, 72 insertions(+), 22 deletions(-) create mode 100644 packages/app-builder/src/services/editor/operators.tsx diff --git a/packages/app-builder/src/components/Edit/EditAstNode.tsx b/packages/app-builder/src/components/Edit/EditAstNode.tsx index 278652d02..4d01982d3 100644 --- a/packages/app-builder/src/components/Edit/EditAstNode.tsx +++ b/packages/app-builder/src/components/Edit/EditAstNode.tsx @@ -1,13 +1,14 @@ import { adaptAstNodeToViewModel, type AstNode } from '@app-builder/models'; import { + useEditorOperators, useGetIdentifierOptions, + useGetOperatorName, useIsEditedOnce, } from '@app-builder/services/editor'; import { Combobox, Select } from '@ui-design-system'; import { forwardRef, useState } from 'react'; import { FormControl, FormField, FormItem } from '../Form'; -//import { useGetOperatorLabel } from '../Scenario/Formula/Operators'; export function EditAstNode({ name }: { name: string }) { const isFirstChildEditedOnce = useIsEditedOnce(`${name}.children.0`); @@ -110,7 +111,8 @@ const EditOperator = forwardRef< onBlur: () => void; } >(({ name, value, onChange, onBlur }, ref) => { - // const getOperatorLabel = useGetOperatorLabel(); + const operators = useEditorOperators(); + const getOperatorName = useGetOperatorName(); return ( - {mockedOperators.map((operator) => { + {operators.map((operator) => { return ( -

- - - {/* {getOperatorLabel(operator)} */} - {operator} - - - {operator} -

+ + + {getOperatorName(operator)} + +
); })} @@ -154,5 +152,3 @@ const EditOperator = forwardRef< ); }); EditOperator.displayName = 'EditOperator'; - -const mockedOperators = ['*', '+', '-', '/', '<', '=', '>'] as const; diff --git a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx index e36564c7c..fddd5f418 100644 --- a/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx +++ b/packages/app-builder/src/routes/__builder/scenarios/$scenarioId/i/$iterationId/edit.rules.$ruleId.tsx @@ -8,7 +8,10 @@ import { EditAstNode, RootOrOperator } from '@app-builder/components/Edit'; import { setToastMessage } from '@app-builder/components/MarbleToaster'; import { Consequence } from '@app-builder/components/Scenario/Rule/Consequence'; import { type AstNode } from '@app-builder/models'; -import { EditorIdentifiersProvider } from '@app-builder/services/editor'; +import { + EditorIdentifiersProvider, + EditorOperatorsProvider, +} from '@app-builder/services/editor'; import { serverServices } from '@app-builder/services/init.server'; import { fromParams, fromUUID } from '@app-builder/utils/short-uuid'; import { DevTool } from '@hookform/devtools'; @@ -37,6 +40,9 @@ export async function loader({ request, params }: LoaderArgs) { ruleId, }); + //TODO: replace this mocked operators with real ones + const mockedOperators = ['*', '+', '-', '/', '<', '=', '>'] as const; + const identifiers = editor.listIdentifiers({ scenarioId, }); @@ -44,6 +50,7 @@ export async function loader({ request, params }: LoaderArgs) { return json({ rule: await scenarioIterationRule, identifiers: await identifiers, + operators: mockedOperators, }); } @@ -97,7 +104,7 @@ export async function action({ request, params }: ActionArgs) { export default function RuleView() { const { t } = useTranslation(handle.i18n); - const { rule, identifiers } = useLoaderData(); + const { rule, identifiers, operators } = useLoaderData(); const fetcher = useFetcher(); //@ts-expect-error recursive type is not supported @@ -134,14 +141,16 @@ export default function RuleView() { - - {/* + + {/* } /> */} - } - /> - + } + /> + +