Skip to content

Commit ef88116

Browse files
committed
feat: ui for AI rule description
1 parent ce4233c commit ef88116

20 files changed

Lines changed: 279 additions & 23 deletions

File tree

packages/app-builder/src/components/AstBuilder/edition/EditionAndRoot.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ export function EditionAstBuilderAndRoot(props: AstBuilderRootProps<AndAstNode>)
2525
const appendChild = () => {
2626
nodeStore.value.node.children.push(NewAndChild());
2727
nodeStore.actions.validate();
28+
nodeStore.actions.triggerUpdate();
2829
};
2930
const removeChild = (index: number) => {
3031
nodeStore.value.node.children.splice(index, 1);
3132
nodeStore.actions.validate();
33+
nodeStore.actions.triggerUpdate();
3234
};
3335

3436
return (

packages/app-builder/src/components/AstBuilder/edition/EditionOrWithAndRoot.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,12 @@ export function EditionAstBuilderOrWithAndRoot(props: AstBuilderRootProps<OrWith
3535
const appendChild = () => {
3636
nodeStore.value.node.children.push(NewChildForOr());
3737
nodeStore.actions.validate();
38+
nodeStore.actions.triggerUpdate();
3839
};
3940
const removeChild = (index: number) => {
4041
nodeStore.value.node.children.splice(index, 1);
4142
nodeStore.actions.validate();
43+
nodeStore.actions.triggerUpdate();
4244
};
4345

4446
return (
@@ -94,6 +96,7 @@ function EditionRootOrGroup({ isFirst, path, removeNode }: EditionRootOrGroupPro
9496

9597
node.value.children.splice(index, 1);
9698
nodeSharp.actions.validate();
99+
nodeSharp.actions.triggerUpdate();
97100
};
98101

99102
return (

packages/app-builder/src/components/AstBuilder/edition/hooks/useRoot.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@ import { type AstBuilderRootProps } from '@ast-builder/types';
44
import { useCallbackRef } from '@marble/shared';
55
import { useEffect, useRef } from 'react';
66

7-
import { AstBuilderNodeSharpFactory, type AstBuilderValidationFn } from '../node-store';
7+
import {
8+
AstBuilderNodeSharpFactory,
9+
AstBuilderUpdateFn,
10+
type AstBuilderValidationFn,
11+
} from '../node-store';
812

913
export function useRoot(props: AstBuilderRootProps, autoValidate = true) {
1014
const scenarioId = AstBuilderDataSharpFactory.select((s) => s.scenarioId);
1115
const onStoreChange = useCallbackRef(props.onStoreChange);
1216
const onValidationUpdate = useCallbackRef(props.onValidationUpdate);
17+
const onUpdate = useCallbackRef(props.onUpdate);
1318

1419
const mutation = useValidateAstMutation({ scenarioId });
1520
const mutationAbortController = useRef<AbortController | null>(null);
@@ -33,11 +38,15 @@ export function useRoot(props: AstBuilderRootProps, autoValidate = true) {
3338

3439
return result;
3540
});
41+
const updateFn = useCallbackRef<AstBuilderUpdateFn>(async (node) => {
42+
onUpdate(node);
43+
});
3644

3745
const nodeStore = AstBuilderNodeSharpFactory.createSharp({
3846
initialNode: props.node,
3947
initialValidation: props.validation ?? { errors: [], evaluation: [] },
4048
validationFn,
49+
updateFn,
4150
});
4251

4352
// Setting a validation function as we are in edit mode

packages/app-builder/src/components/AstBuilder/edition/node-store.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import { createSharpFactory, type InferSharpApi } from 'sharpstate';
66
import { match, P } from 'ts-pattern';
77

88
export type AstBuilderValidationFn = (node: AstNode) => Promise<FlatAstValidation>;
9+
export type AstBuilderUpdateFn = (node: AstNode) => void;
910

1011
export type AstBuilderNodeStoreValue = {
1112
node: AstNode;
1213
validation: FlatAstValidation;
1314
copiedNode: IdLessAstNode | null;
1415
validationFn: AstBuilderValidationFn;
16+
updateFn?: AstBuilderUpdateFn;
1517
};
1618

1719
export const AstBuilderNodeSharpFactory = createSharpFactory({
@@ -20,16 +22,19 @@ export const AstBuilderNodeSharpFactory = createSharpFactory({
2022
initialNode,
2123
initialValidation,
2224
validationFn,
25+
updateFn,
2326
}: {
2427
initialNode: AstNode;
2528
initialValidation: FlatAstValidation;
2629
validationFn: AstBuilderValidationFn;
30+
updateFn?: AstBuilderUpdateFn;
2731
}): AstBuilderNodeStoreValue {
2832
return {
2933
node: clone(initialNode),
3034
validation: initialValidation,
3135
copiedNode: null,
3236
validationFn,
37+
updateFn,
3338
};
3439
},
3540
}).withActions({
@@ -52,6 +57,7 @@ export const AstBuilderNodeSharpFactory = createSharpFactory({
5257
})
5358
.exhaustive();
5459
}
60+
api.value.updateFn?.(clone(api.value.node));
5561
},
5662
async validate(api) {
5763
try {
@@ -71,6 +77,9 @@ export const AstBuilderNodeSharpFactory = createSharpFactory({
7177
copyNode(api, node: IdLessAstNode) {
7278
api.value.copiedNode = clone(node);
7379
},
80+
triggerUpdate(api) {
81+
api.value.updateFn?.(clone(api.value.node));
82+
},
7483
});
7584

7685
export type AstBuilderNodeStore = InferSharpApi<typeof AstBuilderNodeSharpFactory>;

packages/app-builder/src/components/AstBuilder/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type AstBuilderRootProps<NodeType extends AstNode = AstNode> = {
2424
validation?: FlatAstValidation;
2525
onStoreChange?: (nodeStore: InferSharpApi<typeof AstBuilderNodeSharpFactory> | null) => void;
2626
onValidationUpdate?: (validation: FlatAstValidation) => void;
27+
onUpdate?: (node: AstNode) => void;
2728
returnType?: ReturnValueType;
2829
coerceDataType?: AstBuilderOperandProps['coerceDataType'];
2930
optionsDataType?: AstBuilderOperandProps['optionsDataType'];
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { useWritingText } from '@app-builder/hooks/useWritingText';
2+
import { useEffect, useRef, useState } from 'react';
3+
import { useTranslation } from 'react-i18next';
4+
import { Markdown } from 'ui-design-system';
5+
import { Icon } from 'ui-icons';
6+
7+
type AiDescriptionProps = {
8+
isPending: boolean;
9+
description: string | undefined;
10+
};
11+
12+
export function AiDescription({ isPending, description }: AiDescriptionProps) {
13+
const { t } = useTranslation(['scenarios']);
14+
const isInitialRuleLoading = !description && isPending;
15+
const { text: displayedDescription, isDone } = useWritingText(description, 5);
16+
const descriptionElementRef = useRef<HTMLDivElement>(null);
17+
const descriptionContainerRef = useRef<HTMLDivElement>(null);
18+
const [currentHeight, setCurrentHeight] = useState<number | undefined>(undefined);
19+
20+
useEffect(() => {
21+
if (isDone) {
22+
if (descriptionElementRef.current) {
23+
const rect = descriptionElementRef.current.getBoundingClientRect();
24+
setCurrentHeight(rect.height + 2);
25+
}
26+
}
27+
}, [isDone]);
28+
29+
useEffect(() => {
30+
if (descriptionElementRef.current) {
31+
const rect = descriptionElementRef.current.getBoundingClientRect();
32+
if (currentHeight && rect.height > currentHeight - 2) {
33+
setCurrentHeight(undefined);
34+
}
35+
}
36+
}, [displayedDescription]);
37+
38+
return (
39+
<div className="text-default rounded-v2-md border border-purple-96 bg-purple-98 text-purple-65 flex flex-col gap-v2-sm p-v2-md">
40+
<div className="flex items-center gap-v2-xs">
41+
<Icon icon="ai-review" className="size-5" />
42+
{isInitialRuleLoading ? (
43+
<div>{t('scenarios:rules.ai_description.in_progress_title')}</div>
44+
) : (
45+
<div>{t('scenarios:rules.ai_description.title')}</div>
46+
)}
47+
</div>
48+
{description ? (
49+
<div
50+
ref={descriptionContainerRef}
51+
className="bg-white rounded-v2-s border border-l-2 border-l-purple-65 border-grey-95 text-black text-small overflow-hidden transition-all duration-500"
52+
style={{ height: currentHeight ? `${currentHeight}px` : undefined }}
53+
>
54+
<div ref={descriptionElementRef} className="p-v2-sm ">
55+
<Markdown>{displayedDescription}</Markdown>
56+
</div>
57+
</div>
58+
) : null}
59+
{isPending && description ? (
60+
<div>{t('scenarios:rules.ai_description.check_reformulation')}</div>
61+
) : null}
62+
</div>
63+
);
64+
}

packages/app-builder/src/components/Scenario/Screening/FieldAstFormula.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { type BuilderOptionsResource } from '@app-builder/routes/ressources+/sce
55
import { type FlatAstValidation } from '@app-builder/routes/ressources+/scenarios+/$scenarioId+/validate-ast';
66
import { useEditorMode } from '@app-builder/services/editor/editor-mode';
77
import { useGetScenarioErrorMessage } from '@app-builder/services/validation';
8-
import { useEffect, useRef, useState } from 'react';
8+
import { useRef, useState } from 'react';
99
import { Trans, useTranslation } from 'react-i18next';
1010
import { Button } from 'ui-design-system';
1111

@@ -43,21 +43,17 @@ export const FieldAstFormula = ({
4343
const { t } = useTranslation(['scenarios']);
4444
const editor = useEditorMode();
4545

46-
const [formula, setFormula] = useState(astNode ?? defaultValue);
46+
const formula = astNode ?? defaultValue;
4747
const isAstNull = isUndefinedAstNode(formula);
4848
const nodeStoreRef = useRef<AstBuilderNodeStore | null>(null);
4949
const [validationErrors, setValidationErrors] = useState<FlatAstValidation['errors']>([]);
5050

51-
useEffect(() => {
52-
onChange?.(nodeStoreRef.current ? nodeStoreRef.current.value.node : formula);
53-
}, [onChange, formula]);
54-
5551
const handleAddTrigger = () => {
56-
setFormula(NewEmptyTriggerAstNode());
52+
nodeStoreRef.current?.actions.setNodeAtPath('root', NewEmptyTriggerAstNode());
5753
};
5854

5955
const handleDeleteTrigger = () => {
60-
setFormula(defaultValue);
56+
nodeStoreRef.current?.actions.setNodeAtPath('root', defaultValue);
6157
};
6258

6359
return (
@@ -82,6 +78,7 @@ export const FieldAstFormula = ({
8278
onValidationUpdate={(validation) => {
8379
setValidationErrors(validation.errors);
8480
}}
81+
onUpdate={onChange}
8582
returnType="bool"
8683
/>
8784
</AstBuilder.Provider>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
3+
export function useWritingText(text: string | undefined, pace: number = 20) {
4+
const [displayText, setDisplayText] = useState('');
5+
const currentText = useRef(text);
6+
7+
useEffect(() => {
8+
if (text !== currentText.current) {
9+
setDisplayText('');
10+
currentText.current = text;
11+
}
12+
13+
if (!text) {
14+
return;
15+
}
16+
17+
let i = 0;
18+
19+
const intervalId = setInterval(() => {
20+
setDisplayText(text.slice(0, i));
21+
22+
if (++i > text.length) {
23+
clearInterval(intervalId);
24+
}
25+
}, pace);
26+
27+
return () => clearInterval(intervalId);
28+
}, [text, pace]);
29+
30+
return {
31+
text: displayText,
32+
isDone: displayText === text,
33+
};
34+
}

packages/app-builder/src/locales/ar/scenarios.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,9 @@
314314
"operator.is_not_in": "لا يوجد في",
315315
"operator.starts_with": "يبدأ بـ",
316316
"rules.add_group": "أضف مجموعة قواعد",
317+
"rules.ai_description.check_reformulation": "التحقق من التصريح باللغة الإنجليزية ...",
318+
"rules.ai_description.in_progress_title": "التحقق من التصريح باللغة الإنجليزية ...",
319+
"rules.ai_description.title": "التحقق من التصريح باللغة الإنجليزية ...",
317320
"rules.consequence.score_modifier": "مُعدِّل الدرجة: <Score>{{score}}</Score>",
318321
"rules.create": "إنشاء",
319322
"rules.decision": "القرار",

packages/app-builder/src/locales/en/scenarios.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,9 @@
314314
"operator.is_not_in": "is not in",
315315
"operator.starts_with": "starts with",
316316
"rules.add_group": "Add a rule group",
317+
"rules.ai_description.check_reformulation": "Check reformulation...",
318+
"rules.ai_description.in_progress_title": "AI investigation in progress...",
319+
"rules.ai_description.title": "AI investigation",
317320
"rules.consequence.score_modifier": "Score modifier: <Score>{{score}}</Score>",
318321
"rules.create": "Create",
319322
"rules.decision": "Decision",

0 commit comments

Comments
 (0)