From ef88116233537950bb0b6ab61cb4d83bb73c0123 Mon Sep 17 00:00:00 2001 From: Christopher Debove Date: Tue, 7 Oct 2025 12:57:40 +0200 Subject: [PATCH] feat: ui for AI rule description --- .../AstBuilder/edition/EditionAndRoot.tsx | 2 + .../edition/EditionOrWithAndRoot.tsx | 3 + .../AstBuilder/edition/hooks/useRoot.ts | 11 ++- .../AstBuilder/edition/node-store.ts | 9 +++ .../src/components/AstBuilder/types.ts | 1 + .../Scenario/Rules/AiDescription.tsx | 64 +++++++++++++++ .../Scenario/Screening/FieldAstFormula.tsx | 13 ++-- .../app-builder/src/hooks/useWritingText.ts | 34 ++++++++ .../app-builder/src/locales/ar/scenarios.json | 3 + .../app-builder/src/locales/en/scenarios.json | 3 + .../app-builder/src/locales/fr/scenarios.json | 3 + .../src/queries/scenarios/rule-description.ts | 19 +++++ .../ScenarioIterationRuleRepository.ts | 8 ++ .../i+/$iterationId+/rules.$ruleId.tsx | 78 +++++++++++++++---- .../scenarios+/rule-description.tsx | 16 ++++ .../app-builder/src/utils/routes/routes.ts | 5 ++ .../app-builder/src/utils/routes/types.ts | 2 + packages/shared/src/index.ts | 1 + .../shared/src/use-debounced-callback-ref.ts | 25 ++++++ .../src/Markdown/Markdown.tsx | 2 +- 20 files changed, 279 insertions(+), 23 deletions(-) create mode 100644 packages/app-builder/src/components/Scenario/Rules/AiDescription.tsx create mode 100644 packages/app-builder/src/hooks/useWritingText.ts create mode 100644 packages/app-builder/src/queries/scenarios/rule-description.ts create mode 100644 packages/app-builder/src/routes/ressources+/scenarios+/rule-description.tsx create mode 100644 packages/shared/src/use-debounced-callback-ref.ts diff --git a/packages/app-builder/src/components/AstBuilder/edition/EditionAndRoot.tsx b/packages/app-builder/src/components/AstBuilder/edition/EditionAndRoot.tsx index ce9a914cd5..d00364f7d3 100644 --- a/packages/app-builder/src/components/AstBuilder/edition/EditionAndRoot.tsx +++ b/packages/app-builder/src/components/AstBuilder/edition/EditionAndRoot.tsx @@ -25,10 +25,12 @@ export function EditionAstBuilderAndRoot(props: AstBuilderRootProps) const appendChild = () => { nodeStore.value.node.children.push(NewAndChild()); nodeStore.actions.validate(); + nodeStore.actions.triggerUpdate(); }; const removeChild = (index: number) => { nodeStore.value.node.children.splice(index, 1); nodeStore.actions.validate(); + nodeStore.actions.triggerUpdate(); }; return ( diff --git a/packages/app-builder/src/components/AstBuilder/edition/EditionOrWithAndRoot.tsx b/packages/app-builder/src/components/AstBuilder/edition/EditionOrWithAndRoot.tsx index 30e7761ee8..983d2b1bc6 100644 --- a/packages/app-builder/src/components/AstBuilder/edition/EditionOrWithAndRoot.tsx +++ b/packages/app-builder/src/components/AstBuilder/edition/EditionOrWithAndRoot.tsx @@ -35,10 +35,12 @@ export function EditionAstBuilderOrWithAndRoot(props: AstBuilderRootProps { nodeStore.value.node.children.push(NewChildForOr()); nodeStore.actions.validate(); + nodeStore.actions.triggerUpdate(); }; const removeChild = (index: number) => { nodeStore.value.node.children.splice(index, 1); nodeStore.actions.validate(); + nodeStore.actions.triggerUpdate(); }; return ( @@ -94,6 +96,7 @@ function EditionRootOrGroup({ isFirst, path, removeNode }: EditionRootOrGroupPro node.value.children.splice(index, 1); nodeSharp.actions.validate(); + nodeSharp.actions.triggerUpdate(); }; return ( diff --git a/packages/app-builder/src/components/AstBuilder/edition/hooks/useRoot.ts b/packages/app-builder/src/components/AstBuilder/edition/hooks/useRoot.ts index cbbdec72a4..d220bbf851 100644 --- a/packages/app-builder/src/components/AstBuilder/edition/hooks/useRoot.ts +++ b/packages/app-builder/src/components/AstBuilder/edition/hooks/useRoot.ts @@ -4,12 +4,17 @@ import { type AstBuilderRootProps } from '@ast-builder/types'; import { useCallbackRef } from '@marble/shared'; import { useEffect, useRef } from 'react'; -import { AstBuilderNodeSharpFactory, type AstBuilderValidationFn } from '../node-store'; +import { + AstBuilderNodeSharpFactory, + AstBuilderUpdateFn, + type AstBuilderValidationFn, +} from '../node-store'; export function useRoot(props: AstBuilderRootProps, autoValidate = true) { const scenarioId = AstBuilderDataSharpFactory.select((s) => s.scenarioId); const onStoreChange = useCallbackRef(props.onStoreChange); const onValidationUpdate = useCallbackRef(props.onValidationUpdate); + const onUpdate = useCallbackRef(props.onUpdate); const mutation = useValidateAstMutation({ scenarioId }); const mutationAbortController = useRef(null); @@ -33,11 +38,15 @@ export function useRoot(props: AstBuilderRootProps, autoValidate = true) { return result; }); + const updateFn = useCallbackRef(async (node) => { + onUpdate(node); + }); const nodeStore = AstBuilderNodeSharpFactory.createSharp({ initialNode: props.node, initialValidation: props.validation ?? { errors: [], evaluation: [] }, validationFn, + updateFn, }); // Setting a validation function as we are in edit mode diff --git a/packages/app-builder/src/components/AstBuilder/edition/node-store.ts b/packages/app-builder/src/components/AstBuilder/edition/node-store.ts index 96963cfc1c..87fd643224 100644 --- a/packages/app-builder/src/components/AstBuilder/edition/node-store.ts +++ b/packages/app-builder/src/components/AstBuilder/edition/node-store.ts @@ -6,12 +6,14 @@ import { createSharpFactory, type InferSharpApi } from 'sharpstate'; import { match, P } from 'ts-pattern'; export type AstBuilderValidationFn = (node: AstNode) => Promise; +export type AstBuilderUpdateFn = (node: AstNode) => void; export type AstBuilderNodeStoreValue = { node: AstNode; validation: FlatAstValidation; copiedNode: IdLessAstNode | null; validationFn: AstBuilderValidationFn; + updateFn?: AstBuilderUpdateFn; }; export const AstBuilderNodeSharpFactory = createSharpFactory({ @@ -20,16 +22,19 @@ export const AstBuilderNodeSharpFactory = createSharpFactory({ initialNode, initialValidation, validationFn, + updateFn, }: { initialNode: AstNode; initialValidation: FlatAstValidation; validationFn: AstBuilderValidationFn; + updateFn?: AstBuilderUpdateFn; }): AstBuilderNodeStoreValue { return { node: clone(initialNode), validation: initialValidation, copiedNode: null, validationFn, + updateFn, }; }, }).withActions({ @@ -52,6 +57,7 @@ export const AstBuilderNodeSharpFactory = createSharpFactory({ }) .exhaustive(); } + api.value.updateFn?.(clone(api.value.node)); }, async validate(api) { try { @@ -71,6 +77,9 @@ export const AstBuilderNodeSharpFactory = createSharpFactory({ copyNode(api, node: IdLessAstNode) { api.value.copiedNode = clone(node); }, + triggerUpdate(api) { + api.value.updateFn?.(clone(api.value.node)); + }, }); export type AstBuilderNodeStore = InferSharpApi; diff --git a/packages/app-builder/src/components/AstBuilder/types.ts b/packages/app-builder/src/components/AstBuilder/types.ts index 24730f20ef..39f94d831e 100644 --- a/packages/app-builder/src/components/AstBuilder/types.ts +++ b/packages/app-builder/src/components/AstBuilder/types.ts @@ -24,6 +24,7 @@ export type AstBuilderRootProps = { validation?: FlatAstValidation; onStoreChange?: (nodeStore: InferSharpApi | null) => void; onValidationUpdate?: (validation: FlatAstValidation) => void; + onUpdate?: (node: AstNode) => void; returnType?: ReturnValueType; coerceDataType?: AstBuilderOperandProps['coerceDataType']; optionsDataType?: AstBuilderOperandProps['optionsDataType']; diff --git a/packages/app-builder/src/components/Scenario/Rules/AiDescription.tsx b/packages/app-builder/src/components/Scenario/Rules/AiDescription.tsx new file mode 100644 index 0000000000..63a2eb9d45 --- /dev/null +++ b/packages/app-builder/src/components/Scenario/Rules/AiDescription.tsx @@ -0,0 +1,64 @@ +import { useWritingText } from '@app-builder/hooks/useWritingText'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Markdown } from 'ui-design-system'; +import { Icon } from 'ui-icons'; + +type AiDescriptionProps = { + isPending: boolean; + description: string | undefined; +}; + +export function AiDescription({ isPending, description }: AiDescriptionProps) { + const { t } = useTranslation(['scenarios']); + const isInitialRuleLoading = !description && isPending; + const { text: displayedDescription, isDone } = useWritingText(description, 5); + const descriptionElementRef = useRef(null); + const descriptionContainerRef = useRef(null); + const [currentHeight, setCurrentHeight] = useState(undefined); + + useEffect(() => { + if (isDone) { + if (descriptionElementRef.current) { + const rect = descriptionElementRef.current.getBoundingClientRect(); + setCurrentHeight(rect.height + 2); + } + } + }, [isDone]); + + useEffect(() => { + if (descriptionElementRef.current) { + const rect = descriptionElementRef.current.getBoundingClientRect(); + if (currentHeight && rect.height > currentHeight - 2) { + setCurrentHeight(undefined); + } + } + }, [displayedDescription]); + + return ( +
+
+ + {isInitialRuleLoading ? ( +
{t('scenarios:rules.ai_description.in_progress_title')}
+ ) : ( +
{t('scenarios:rules.ai_description.title')}
+ )} +
+ {description ? ( +
+
+ {displayedDescription} +
+
+ ) : null} + {isPending && description ? ( +
{t('scenarios:rules.ai_description.check_reformulation')}
+ ) : null} +
+ ); +} diff --git a/packages/app-builder/src/components/Scenario/Screening/FieldAstFormula.tsx b/packages/app-builder/src/components/Scenario/Screening/FieldAstFormula.tsx index bf992b23e8..f574011506 100644 --- a/packages/app-builder/src/components/Scenario/Screening/FieldAstFormula.tsx +++ b/packages/app-builder/src/components/Scenario/Screening/FieldAstFormula.tsx @@ -5,7 +5,7 @@ import { type BuilderOptionsResource } from '@app-builder/routes/ressources+/sce import { type FlatAstValidation } from '@app-builder/routes/ressources+/scenarios+/$scenarioId+/validate-ast'; import { useEditorMode } from '@app-builder/services/editor/editor-mode'; import { useGetScenarioErrorMessage } from '@app-builder/services/validation'; -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { Button } from 'ui-design-system'; @@ -43,21 +43,17 @@ export const FieldAstFormula = ({ const { t } = useTranslation(['scenarios']); const editor = useEditorMode(); - const [formula, setFormula] = useState(astNode ?? defaultValue); + const formula = astNode ?? defaultValue; const isAstNull = isUndefinedAstNode(formula); const nodeStoreRef = useRef(null); const [validationErrors, setValidationErrors] = useState([]); - useEffect(() => { - onChange?.(nodeStoreRef.current ? nodeStoreRef.current.value.node : formula); - }, [onChange, formula]); - const handleAddTrigger = () => { - setFormula(NewEmptyTriggerAstNode()); + nodeStoreRef.current?.actions.setNodeAtPath('root', NewEmptyTriggerAstNode()); }; const handleDeleteTrigger = () => { - setFormula(defaultValue); + nodeStoreRef.current?.actions.setNodeAtPath('root', defaultValue); }; return ( @@ -82,6 +78,7 @@ export const FieldAstFormula = ({ onValidationUpdate={(validation) => { setValidationErrors(validation.errors); }} + onUpdate={onChange} returnType="bool" /> diff --git a/packages/app-builder/src/hooks/useWritingText.ts b/packages/app-builder/src/hooks/useWritingText.ts new file mode 100644 index 0000000000..fc035a2a46 --- /dev/null +++ b/packages/app-builder/src/hooks/useWritingText.ts @@ -0,0 +1,34 @@ +import { useEffect, useRef, useState } from 'react'; + +export function useWritingText(text: string | undefined, pace: number = 20) { + const [displayText, setDisplayText] = useState(''); + const currentText = useRef(text); + + useEffect(() => { + if (text !== currentText.current) { + setDisplayText(''); + currentText.current = text; + } + + if (!text) { + return; + } + + let i = 0; + + const intervalId = setInterval(() => { + setDisplayText(text.slice(0, i)); + + if (++i > text.length) { + clearInterval(intervalId); + } + }, pace); + + return () => clearInterval(intervalId); + }, [text, pace]); + + return { + text: displayText, + isDone: displayText === text, + }; +} diff --git a/packages/app-builder/src/locales/ar/scenarios.json b/packages/app-builder/src/locales/ar/scenarios.json index c0b3e8e7a5..e636192318 100644 --- a/packages/app-builder/src/locales/ar/scenarios.json +++ b/packages/app-builder/src/locales/ar/scenarios.json @@ -314,6 +314,9 @@ "operator.is_not_in": "لا يوجد في", "operator.starts_with": "يبدأ بـ", "rules.add_group": "أضف مجموعة قواعد", + "rules.ai_description.check_reformulation": "التحقق من التصريح باللغة الإنجليزية ...", + "rules.ai_description.in_progress_title": "التحقق من التصريح باللغة الإنجليزية ...", + "rules.ai_description.title": "التحقق من التصريح باللغة الإنجليزية ...", "rules.consequence.score_modifier": "مُعدِّل الدرجة: {{score}}", "rules.create": "إنشاء", "rules.decision": "القرار", diff --git a/packages/app-builder/src/locales/en/scenarios.json b/packages/app-builder/src/locales/en/scenarios.json index 54947ee7a9..45980fafd7 100644 --- a/packages/app-builder/src/locales/en/scenarios.json +++ b/packages/app-builder/src/locales/en/scenarios.json @@ -314,6 +314,9 @@ "operator.is_not_in": "is not in", "operator.starts_with": "starts with", "rules.add_group": "Add a rule group", + "rules.ai_description.check_reformulation": "Check reformulation...", + "rules.ai_description.in_progress_title": "AI investigation in progress...", + "rules.ai_description.title": "AI investigation", "rules.consequence.score_modifier": "Score modifier: {{score}}", "rules.create": "Create", "rules.decision": "Decision", diff --git a/packages/app-builder/src/locales/fr/scenarios.json b/packages/app-builder/src/locales/fr/scenarios.json index 9675bb7c27..5ca4b15224 100644 --- a/packages/app-builder/src/locales/fr/scenarios.json +++ b/packages/app-builder/src/locales/fr/scenarios.json @@ -314,6 +314,9 @@ "operator.is_not_in": "n'est pas dans", "operator.starts_with": "commence par", "rules.add_group": "Ajouter un groupe de règles", + "rules.ai_description.check_reformulation": "Vérification de la réformulation...", + "rules.ai_description.in_progress_title": "Reformulation IA en cours...", + "rules.ai_description.title": "Reformulation IA", "rules.consequence.score_modifier": "Score : {{score}}", "rules.create": "Créer", "rules.decision": "Décision", diff --git a/packages/app-builder/src/queries/scenarios/rule-description.ts b/packages/app-builder/src/queries/scenarios/rule-description.ts new file mode 100644 index 0000000000..b0fee26514 --- /dev/null +++ b/packages/app-builder/src/queries/scenarios/rule-description.ts @@ -0,0 +1,19 @@ +import { AstNode } from '@app-builder/models'; +import { getRoute } from '@app-builder/utils/routes'; +import { useMutation } from '@tanstack/react-query'; + +export type RuleDescriptionPayload = { + astNode: AstNode; +}; + +const endpoint = getRoute('/ressources/scenarios/rule-description'); + +export const useRuleDescriptionMutation = (identifier?: string) => { + return useMutation({ + mutationKey: ['scenario-iteration-rule', 'rule-description', identifier], + mutationFn: async (payload: RuleDescriptionPayload) => { + const response = await fetch(endpoint, { method: 'POST', body: JSON.stringify(payload) }); + return response.json() as Promise<{ success: true; data: string }>; + }, + }); +}; diff --git a/packages/app-builder/src/repositories/ScenarioIterationRuleRepository.ts b/packages/app-builder/src/repositories/ScenarioIterationRuleRepository.ts index 4ba6a632df..99563d4bef 100644 --- a/packages/app-builder/src/repositories/ScenarioIterationRuleRepository.ts +++ b/packages/app-builder/src/repositories/ScenarioIterationRuleRepository.ts @@ -1,4 +1,5 @@ import { type MarbleCoreApi } from '@app-builder/infra/marblecore-api'; +import { AstNode, adaptNodeDto } from '@app-builder/models'; import { adaptCreateScenarioIterationRuleBodyDto, adaptScenarioIterationRule, @@ -14,6 +15,7 @@ export interface ScenarioIterationRuleRepository { createRule(args: CreateScenarioIterationRuleInput): Promise; updateRule(args: UpdateScenarioIterationRuleInput): Promise; deleteRule(args: { ruleId: string }): Promise; + getRuleDescription(args: { astNode: AstNode }): Promise; } export function makeGetScenarioIterationRuleRepository() { @@ -42,5 +44,11 @@ export function makeGetScenarioIterationRuleRepository() { deleteRule: async ({ ruleId }) => { await marbleCoreApiClient.deleteScenarioIterationRule(ruleId); }, + getRuleDescription: async ({ astNode }) => { + const { description } = await marbleCoreApiClient.generateAiDescriptionForAstExpression({ + ast_expression: adaptNodeDto(astNode), + }); + return description; + }, }); } diff --git a/packages/app-builder/src/routes/_builder+/scenarios+/$scenarioId+/i+/$iterationId+/rules.$ruleId.tsx b/packages/app-builder/src/routes/_builder+/scenarios+/$scenarioId+/i+/$iterationId+/rules.$ruleId.tsx index 00797693bc..8e3cc47214 100644 --- a/packages/app-builder/src/routes/_builder+/scenarios+/$scenarioId+/i+/$iterationId+/rules.$ruleId.tsx +++ b/packages/app-builder/src/routes/_builder+/scenarios+/$scenarioId+/i+/$iterationId+/rules.$ruleId.tsx @@ -9,10 +9,12 @@ import { FormInput } from '@app-builder/components/Form/Tanstack/FormInput'; import { setToastMessage } from '@app-builder/components/MarbleToaster'; import { DeleteRule } from '@app-builder/components/Scenario/Rules/Actions/DeleteRule'; import { DuplicateRule } from '@app-builder/components/Scenario/Rules/Actions/DuplicateRule'; +import { AiDescription } from '@app-builder/components/Scenario/Rules/AiDescription'; import { FieldAstFormula } from '@app-builder/components/Scenario/Screening/FieldAstFormula'; import { FieldRuleGroup } from '@app-builder/components/Scenario/Screening/FieldRuleGroup'; import useIntersection from '@app-builder/hooks/useIntersection'; -import { NewEmptyRuleAstNode } from '@app-builder/models'; +import { AstNode, NewEmptyRuleAstNode } from '@app-builder/models'; +import { useRuleDescriptionMutation } from '@app-builder/queries/scenarios/rule-description'; import { useCurrentScenario } from '@app-builder/routes/_builder+/scenarios+/$scenarioId+/_layout'; import { useEditorMode } from '@app-builder/services/editor/editor-mode'; import { initServerServices } from '@app-builder/services/init.server'; @@ -20,16 +22,16 @@ import { getFieldErrors } from '@app-builder/utils/form'; import { getRoute } from '@app-builder/utils/routes'; import { fromParams, fromUUIDtoSUUID, useParam } from '@app-builder/utils/short-uuid'; import * as Ariakit from '@ariakit/react'; +import { useDebouncedCallbackRef } from '@marble/shared'; import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'; import { useFetcher, useLoaderData } from '@remix-run/react'; import { useForm } from '@tanstack/react-form'; import { type Namespace } from 'i18next'; -import { useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, CtaClassName, cn, Tag } from 'ui-design-system'; import { Icon } from 'ui-icons'; import { z } from 'zod/v4'; - import { useCurrentScenarioIterationRule, useRuleGroups } from './_layout'; export const handle = { @@ -83,7 +85,7 @@ export const handle = { }; export async function loader({ request, params }: LoaderFunctionArgs) { - const { authService } = initServerServices(request); + const { authService, appConfigRepository } = initServerServices(request); const { customListsRepository, editor, dataModelRepository } = await authService.isAuthenticated( request, { @@ -91,17 +93,20 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }, ); - const [{ databaseAccessors, payloadAccessors }, dataModel, customLists] = await Promise.all([ - editor.listAccessors({ scenarioId: fromParams(params, 'scenarioId') }), - dataModelRepository.getDataModel(), - customListsRepository.listCustomLists(), - ]); + const [{ databaseAccessors, payloadAccessors }, dataModel, customLists, appConfig] = + await Promise.all([ + editor.listAccessors({ scenarioId: fromParams(params, 'scenarioId') }), + dataModelRepository.getDataModel(), + customListsRepository.listCustomLists(), + appConfigRepository.getAppConfig(), + ]); return { databaseAccessors, payloadAccessors, dataModel, customLists, + isAiRuleDescriptionEnabled: appConfig.isManagedMarble, }; } @@ -173,8 +178,13 @@ export async function action({ request, params }: ActionFunctionArgs) { } export default function RuleDetail() { - const { databaseAccessors, payloadAccessors, dataModel, customLists } = - useLoaderData(); + const { + databaseAccessors, + payloadAccessors, + dataModel, + customLists, + isAiRuleDescriptionEnabled, + } = useLoaderData(); const { t } = useTranslation(handle.i18n); const iterationId = useParam('iterationId'); @@ -206,6 +216,40 @@ export default function RuleDetail() { defaultValues: rule as EditRuleForm, }); + const ruleDescriptionMutation = useRuleDescriptionMutation(rule.id); + const [ruleDescription, setRuleDescription] = useState(undefined); + const [isDebouncing, setIsDebouncing] = useState(false); + + useEffect(() => { + if (!isAiRuleDescriptionEnabled) return; + + setRuleDescription(undefined); + if (rule.formula) { + ruleDescriptionMutation.mutateAsync({ astNode: rule.formula }).then((res) => { + if (res.success && !ruleDescription) { + setRuleDescription(res.data); + } + }); + } + }, [rule.id]); + + const innerHandleFormulaChange = useDebouncedCallbackRef((value: AstNode | undefined) => { + setIsDebouncing(false); + if (value) { + ruleDescriptionMutation.mutateAsync({ astNode: value }).then((res) => { + if (res.success) { + setRuleDescription(res.data); + } + }); + } + }, 3000); + const handleFormulaChange = (value: AstNode | undefined) => { + if (!isAiRuleDescriptionEnabled) return; + + setIsDebouncing(true); + innerHandleFormulaChange(value); + }; + const options = { databaseAccessors, payloadAccessors, @@ -359,8 +403,13 @@ export default function RuleDetail() { )} -
+ {isAiRuleDescriptionEnabled ? ( + + ) : null} {t('scenarios:edit_rule.formula')}
{ + field.handleChange(node); + handleFormulaChange(node); + }} astNode={field.state.value} defaultValue={NewEmptyRuleAstNode()} /> diff --git a/packages/app-builder/src/routes/ressources+/scenarios+/rule-description.tsx b/packages/app-builder/src/routes/ressources+/scenarios+/rule-description.tsx new file mode 100644 index 0000000000..3d61e5c4e2 --- /dev/null +++ b/packages/app-builder/src/routes/ressources+/scenarios+/rule-description.tsx @@ -0,0 +1,16 @@ +import { initServerServices } from '@app-builder/services/init.server'; +import { getRoute } from '@app-builder/utils/routes'; +import { ActionFunctionArgs } from '@remix-run/server-runtime'; + +export async function action({ request }: ActionFunctionArgs) { + const { authService } = initServerServices(request); + const { scenarioIterationRuleRepository } = await authService.isAuthenticated(request, { + failureRedirect: getRoute('/sign-in'), + }); + + const { astNode } = await request.json(); + + const description = await scenarioIterationRuleRepository.getRuleDescription({ astNode }); + + return Response.json({ success: true, data: description }); +} diff --git a/packages/app-builder/src/utils/routes/routes.ts b/packages/app-builder/src/utils/routes/routes.ts index 3662531361..ceaa94f63a 100644 --- a/packages/app-builder/src/utils/routes/routes.ts +++ b/packages/app-builder/src/utils/routes/routes.ts @@ -736,6 +736,11 @@ export const routes = [ "path": "ressources/scenarios/iteration/:iterationId/get-rules", "file": "routes/ressources+/scenarios+/iteration+/$iterationId.get-rules.tsx" }, + { + "id": "routes/ressources+/scenarios+/rule-description", + "path": "ressources/scenarios/rule-description", + "file": "routes/ressources+/scenarios+/rule-description.tsx" + }, { "id": "routes/ressources+/scenarios+/update", "path": "ressources/scenarios/update", diff --git a/packages/app-builder/src/utils/routes/types.ts b/packages/app-builder/src/utils/routes/types.ts index 3a985b0ed6..80eb0a8cf1 100644 --- a/packages/app-builder/src/utils/routes/types.ts +++ b/packages/app-builder/src/utils/routes/types.ts @@ -126,6 +126,7 @@ export type RoutePath = | '/ressources/scenarios/:scenarioId/validate-ast' | '/ressources/scenarios/create' | '/ressources/scenarios/iteration/:iterationId/get-rules' + | '/ressources/scenarios/rule-description' | '/ressources/scenarios/update' | '/ressources/screenings/download/:screeningId/:fileId' | '/ressources/screenings/enrich-match/:matchId' @@ -318,6 +319,7 @@ export type RouteID = | 'routes/ressources+/scenarios+/$scenarioId+/validate-ast' | 'routes/ressources+/scenarios+/create' | 'routes/ressources+/scenarios+/iteration+/$iterationId.get-rules' + | 'routes/ressources+/scenarios+/rule-description' | 'routes/ressources+/scenarios+/update' | 'routes/ressources+/screenings+/download.$screeningId.$fileId' | 'routes/ressources+/screenings+/enrich-match.$matchId' diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0e1657acec..ac066ae54f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,5 @@ // export * from './component-state'; export * from './simple-context'; export * from './use-callback-ref'; +export * from './use-debounced-callback-ref'; export * from './use-ref-fn'; diff --git a/packages/shared/src/use-debounced-callback-ref.ts b/packages/shared/src/use-debounced-callback-ref.ts new file mode 100644 index 0000000000..84a71f5f04 --- /dev/null +++ b/packages/shared/src/use-debounced-callback-ref.ts @@ -0,0 +1,25 @@ +import React from 'react'; + +function debounce void>(callback: T, delay: number): T { + let timeoutId: ReturnType; + + return ((...args) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => callback(...args), delay); + }) as T; +} + +export function useDebouncedCallbackRef void>( + callback: T | undefined, + delay: number, +): T { + const callbackRef = React.useRef(); + callbackRef.current = callback; + + const debouncedFn = React.useRef(); + React.useEffect(() => { + debouncedFn.current = debounce(((...args) => callbackRef.current?.(...args)) as T, delay); + }, [delay]); + + return React.useMemo(() => ((...args) => debouncedFn.current?.(...args)) as T, []); +} diff --git a/packages/ui-design-system/src/Markdown/Markdown.tsx b/packages/ui-design-system/src/Markdown/Markdown.tsx index e64201bb4c..b7d96ec3fb 100644 --- a/packages/ui-design-system/src/Markdown/Markdown.tsx +++ b/packages/ui-design-system/src/Markdown/Markdown.tsx @@ -43,7 +43,7 @@ export function Markdown({ children }: { children: string }) { h1: ({ children }) =>
{children}
, h2: ({ children }) =>
{children}
, h3: ({ children }) =>
{children}
, - p: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, ul: ({ children }) =>
    {children}
, ol: ({ children }) =>
    {children}
, code: ({ children }) => {children},