Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ export function EditionAstBuilderAndRoot(props: AstBuilderRootProps<AndAstNode>)
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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ export function EditionAstBuilderOrWithAndRoot(props: AstBuilderRootProps<OrWith
const appendChild = () => {
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 (
Expand Down Expand Up @@ -94,6 +96,7 @@ function EditionRootOrGroup({ isFirst, path, removeNode }: EditionRootOrGroupPro

node.value.children.splice(index, 1);
nodeSharp.actions.validate();
nodeSharp.actions.triggerUpdate();
};

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AbortController | null>(null);
Expand All @@ -33,11 +38,15 @@ export function useRoot(props: AstBuilderRootProps, autoValidate = true) {

return result;
});
const updateFn = useCallbackRef<AstBuilderUpdateFn>(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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import { createSharpFactory, type InferSharpApi } from 'sharpstate';
import { match, P } from 'ts-pattern';

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

export type AstBuilderNodeStoreValue = {
node: AstNode;
validation: FlatAstValidation;
copiedNode: IdLessAstNode | null;
validationFn: AstBuilderValidationFn;
updateFn?: AstBuilderUpdateFn;
};

export const AstBuilderNodeSharpFactory = createSharpFactory({
Expand All @@ -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({
Expand All @@ -52,6 +57,7 @@ export const AstBuilderNodeSharpFactory = createSharpFactory({
})
.exhaustive();
}
api.value.updateFn?.(clone(api.value.node));
},
async validate(api) {
try {
Expand All @@ -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<typeof AstBuilderNodeSharpFactory>;
1 change: 1 addition & 0 deletions packages/app-builder/src/components/AstBuilder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type AstBuilderRootProps<NodeType extends AstNode = AstNode> = {
validation?: FlatAstValidation;
onStoreChange?: (nodeStore: InferSharpApi<typeof AstBuilderNodeSharpFactory> | null) => void;
onValidationUpdate?: (validation: FlatAstValidation) => void;
onUpdate?: (node: AstNode) => void;
returnType?: ReturnValueType;
coerceDataType?: AstBuilderOperandProps['coerceDataType'];
optionsDataType?: AstBuilderOperandProps['optionsDataType'];
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const descriptionContainerRef = useRef<HTMLDivElement>(null);
const [currentHeight, setCurrentHeight] = useState<number | undefined>(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 (
<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">
<div className="flex items-center gap-v2-xs">
<Icon icon="ai-review" className="size-5" />
{isInitialRuleLoading ? (
<div>{t('scenarios:rules.ai_description.in_progress_title')}</div>
) : (
<div>{t('scenarios:rules.ai_description.title')}</div>
)}
</div>
{description ? (
<div
ref={descriptionContainerRef}
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"
style={{ height: currentHeight ? `${currentHeight}px` : undefined }}
>
<div ref={descriptionElementRef} className="p-v2-sm ">
<Markdown>{displayedDescription}</Markdown>
</div>
</div>
) : null}
{isPending && description ? (
<div>{t('scenarios:rules.ai_description.check_reformulation')}</div>
) : null}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<AstBuilderNodeStore | null>(null);
const [validationErrors, setValidationErrors] = useState<FlatAstValidation['errors']>([]);

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 (
Expand All @@ -82,6 +78,7 @@ export const FieldAstFormula = ({
onValidationUpdate={(validation) => {
setValidationErrors(validation.errors);
}}
onUpdate={onChange}
returnType="bool"
/>
</AstBuilder.Provider>
Expand Down
34 changes: 34 additions & 0 deletions packages/app-builder/src/hooks/useWritingText.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
3 changes: 3 additions & 0 deletions packages/app-builder/src/locales/ar/scenarios.json
Original file line number Diff line number Diff line change
Expand Up @@ -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>{{score}}</Score>",
"rules.create": "إنشاء",
"rules.decision": "القرار",
Expand Down
3 changes: 3 additions & 0 deletions packages/app-builder/src/locales/en/scenarios.json
Original file line number Diff line number Diff line change
Expand Up @@ -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>{{score}}</Score>",
"rules.create": "Create",
"rules.decision": "Decision",
Expand Down
3 changes: 3 additions & 0 deletions packages/app-builder/src/locales/fr/scenarios.json
Original file line number Diff line number Diff line change
Expand Up @@ -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>{{score}}</Score>",
"rules.create": "Créer",
"rules.decision": "Décision",
Expand Down
19 changes: 19 additions & 0 deletions packages/app-builder/src/queries/scenarios/rule-description.ts
Original file line number Diff line number Diff line change
@@ -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 }>;
},
});
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type MarbleCoreApi } from '@app-builder/infra/marblecore-api';
import { AstNode, adaptNodeDto } from '@app-builder/models';
import {
adaptCreateScenarioIterationRuleBodyDto,
adaptScenarioIterationRule,
Expand All @@ -14,6 +15,7 @@ export interface ScenarioIterationRuleRepository {
createRule(args: CreateScenarioIterationRuleInput): Promise<ScenarioIterationRule>;
updateRule(args: UpdateScenarioIterationRuleInput): Promise<ScenarioIterationRule>;
deleteRule(args: { ruleId: string }): Promise<void>;
getRuleDescription(args: { astNode: AstNode }): Promise<string>;
}

export function makeGetScenarioIterationRuleRepository() {
Expand Down Expand Up @@ -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;
},
});
}
Loading