Skip to content

Commit d3ddaa9

Browse files
refactor: restructure ast builder MAR-796 (#735)
refactor: restructure ast builder add ids on ast nodes implement RootAnd and RootOrWithAnd manage options as idless node to inject ids on them when selecting them manage autovalidate with request abortion make operand menu accessible coerce search to constant edit modals IsMultipleOf, TimeAdd edit modals TimestampExtract, FuzzyMatchComparator implement operand infos with some viewing fields implement view mode & replace ast builders accross the app replaced all operands usage
1 parent 87dbbd8 commit d3ddaa9

File tree

164 files changed

+6121
-6509
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

164 files changed

+6121
-6509
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,5 @@ firebase-debug.log
4747
ui-debug.log
4848
# Sentry Config File
4949
.sentryclirc
50+
51+
.jj/

packages/app-builder/package.json

+8
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,15 @@
4848
"@hookform/devtools": "^4.3.1",
4949
"@hookform/resolvers": "^3.9.0",
5050
"@lottiefiles/react-lottie-player": "^3.5.4",
51+
"@marble/shared": "workspace:*",
5152
"@oazapfts/runtime": "^1.0.3",
53+
"@preact/signals-react": "^3.0.1",
5254
"@radix-ui/react-avatar": "^1.1.1",
5355
"@radix-ui/react-checkbox": "^1.1.2",
5456
"@radix-ui/react-collapsible": "^1.1.1",
5557
"@radix-ui/react-dialog": "^1.1.2",
5658
"@radix-ui/react-dropdown-menu": "^2.1.2",
59+
"@radix-ui/react-hover-card": "^1.1.6",
5760
"@radix-ui/react-label": "^2.1.0",
5861
"@radix-ui/react-popover": "^1.1.2",
5962
"@radix-ui/react-scroll-area": "^1.2.0",
@@ -70,15 +73,18 @@
7073
"@sentry/remix": "^8.52.0",
7174
"@swan-io/boxed": "^3.2.0",
7275
"@tanstack/react-form": "^0.41.3",
76+
"@tanstack/react-query": "^5.66.7",
7377
"@tanstack/react-table": "^8.20.5",
7478
"@tanstack/react-virtual": "3.10.8",
7579
"autosuggest-highlight": "^3.3.4",
7680
"class-variance-authority": "^0.7.0",
7781
"clsx": "^2.1.1",
82+
"cmdk": "^1.0.4",
7883
"cronstrue": "^2.50.0",
7984
"crypto-js": "^4.2.0",
8085
"date-fns": "^4.1.0",
8186
"decode-formdata": "^0.8.0",
87+
"deepsignal": "^1.6.0",
8288
"dinero.js": "2.0.0-alpha.14",
8389
"firebase": "^10.14.1",
8490
"i18next": "^23.16.2",
@@ -103,6 +109,7 @@
103109
"remeda": "^2.15.2",
104110
"remix-i18next": "^6.4.1",
105111
"remix-utils": "^7.7.0",
112+
"sharpstate": "^0.0.13",
106113
"short-uuid": "^5.2.0",
107114
"spin-delay": "^2.0.1",
108115
"swagger-ui-react": "^5.17.14",
@@ -112,6 +119,7 @@
112119
"typescript-utils": "workspace:*",
113120
"ui-design-system": "workspace:*",
114121
"ui-icons": "workspace:*",
122+
"uuid": "^11.1.0",
115123
"zod": "^3.24.1",
116124
"zustand": "^5.0.0"
117125
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { type AstNode, type DataType, type EnumValue } from '@app-builder/models';
2+
import { type KnownOperandAstNode } from '@app-builder/models/astNode/builder-ast-node';
3+
4+
import { EditionAstBuilderOperand } from './edition/EditionOperand';
5+
import { type EnrichedMenuOption } from './edition/helpers';
6+
import { AstBuilderDataSharpFactory } from './Provider';
7+
import { type AstBuilderBaseProps } from './types';
8+
import { ViewingAstBuilderOperand } from './viewing/ViewingOperand';
9+
10+
export type AstBuilderOperandProps = AstBuilderBaseProps<KnownOperandAstNode> & {
11+
enumValues?: EnumValue[];
12+
showErrors?: boolean;
13+
placeholder?: string;
14+
onChange?: (node: AstNode) => void;
15+
optionsDataType?: DataType[] | ((o: EnrichedMenuOption) => boolean);
16+
coerceDataType?: DataType[];
17+
returnValue?: string;
18+
} & { validationStatus?: 'valid' | 'error' | 'light-error' };
19+
20+
export function AstBuilderOperand(props: AstBuilderOperandProps) {
21+
const builderMode = AstBuilderDataSharpFactory.select((s) => s.mode);
22+
return builderMode === 'edit' ? (
23+
<EditionAstBuilderOperand {...props} />
24+
) : (
25+
<ViewingAstBuilderOperand {...props} />
26+
);
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import {
2+
type DataModel,
3+
type DataType,
4+
getDataTypeIcon,
5+
getDataTypeTKey,
6+
type IdLessAstNode,
7+
type TableModel,
8+
} from '@app-builder/models';
9+
import {
10+
type AggregationAstNode,
11+
isAggregation,
12+
isBinaryAggregationFilter,
13+
} from '@app-builder/models/astNode/aggregation';
14+
import {
15+
type CustomListAccessAstNode,
16+
isCustomListAccess,
17+
} from '@app-builder/models/astNode/custom-list';
18+
import {
19+
type DataAccessorAstNode,
20+
isDataAccessorAstNode,
21+
} from '@app-builder/models/astNode/data-accessor';
22+
import { isTimeAdd } from '@app-builder/models/astNode/time';
23+
import { type CustomList } from '@app-builder/models/custom-list';
24+
import {
25+
getOperandTypeIcon,
26+
getOperandTypeTKey,
27+
type OperandType,
28+
} from '@app-builder/models/operand-type';
29+
import { getDataAccessorAstNodeField } from '@app-builder/services/ast-node/getDataAccessorAstNodeField';
30+
import {
31+
HoverCard,
32+
HoverCardContent,
33+
HoverCardPortal,
34+
HoverCardTrigger,
35+
} from '@radix-ui/react-hover-card';
36+
import clsx from 'clsx';
37+
import { Fragment } from 'react';
38+
import { useTranslation } from 'react-i18next';
39+
import { Icon } from 'ui-icons';
40+
41+
import { AstBuilderDataSharpFactory } from './Provider';
42+
import { LogicalOperatorLabel } from './styles/LogicalOperatorLabel';
43+
import { ViewingAstBuilderOperand } from './viewing/ViewingOperand';
44+
import { ViewingOperator } from './viewing/ViewingOperator';
45+
46+
const MAX_ENUM_VALUES = 50;
47+
48+
type OperandInfosProps = {
49+
node: IdLessAstNode;
50+
dataType: DataType;
51+
operandType: OperandType;
52+
displayName: string;
53+
};
54+
55+
const contentClassnames = clsx([
56+
'flex flex-col w-full flex-1 overflow-hidden',
57+
'bg-grey-100 border-grey-90 rounded border shadow-md outline-none',
58+
]);
59+
60+
export function OperandInfos(props: OperandInfosProps) {
61+
return (
62+
<HoverCard openDelay={50} closeDelay={200}>
63+
<HoverCardTrigger asChild>
64+
<Icon
65+
icon="tip"
66+
className="group-hover:hover:text-purple-65 group-hover:text-purple-82 data-[state=open]:text-purple-65 size-5 shrink-0 text-transparent"
67+
/>
68+
</HoverCardTrigger>
69+
<HoverCardPortal>
70+
<HoverCardContent
71+
side="right"
72+
align="start"
73+
sideOffset={20}
74+
alignOffset={-8}
75+
className={contentClassnames}
76+
>
77+
<div className="bg-grey-100 flex flex-col gap-2 overflow-auto p-4">
78+
<div className="flex flex-col gap-1">
79+
<TypeInfos operandType={props.operandType} dataType={props.dataType} />
80+
<p className="text-grey-00 text-s text-ellipsis hyphens-auto font-normal">
81+
{props.displayName}
82+
</p>
83+
</div>
84+
<OperandDescription node={props.node} />
85+
</div>
86+
</HoverCardContent>
87+
</HoverCardPortal>
88+
</HoverCard>
89+
);
90+
}
91+
92+
type TypeInfosProps = Pick<OperandInfosProps, 'operandType' | 'dataType'>;
93+
function TypeInfos({ operandType, dataType }: TypeInfosProps) {
94+
const { t } = useTranslation(['scenarios']);
95+
const typeInfos = [
96+
{
97+
icon: getOperandTypeIcon(operandType),
98+
tKey: getOperandTypeTKey(operandType),
99+
},
100+
{
101+
icon: getDataTypeIcon(dataType),
102+
tKey: getDataTypeTKey(dataType),
103+
},
104+
];
105+
if (typeInfos.filter(({ tKey }) => !!tKey).length === 0) return null;
106+
107+
return (
108+
<div className="flex flex-row gap-2">
109+
{typeInfos.map(({ icon, tKey }) => {
110+
if (!tKey) return null;
111+
return (
112+
<span
113+
key={tKey}
114+
className="text-purple-82 inline-flex items-center gap-[2px] text-xs font-normal"
115+
>
116+
{icon ? <Icon icon={icon} className="size-3" /> : null}
117+
{t(tKey, { count: 1 })}
118+
</span>
119+
);
120+
})}
121+
</div>
122+
);
123+
}
124+
125+
type OperandDescriptionProps = Pick<OperandInfosProps, 'node'>;
126+
function OperandDescription({ node }: OperandDescriptionProps) {
127+
const { t } = useTranslation(['scenarios']);
128+
const dataSharp = AstBuilderDataSharpFactory.useSharp();
129+
const data = dataSharp.select((s) => s.data);
130+
131+
if (isAggregation(node)) {
132+
return <AggregatorDescription node={node} />;
133+
}
134+
if (isCustomListAccess(node)) {
135+
return <CustomListAccessDescription node={node} customLists={data.customLists} />;
136+
}
137+
if (isDataAccessorAstNode(node)) {
138+
return (
139+
<DataAccessorDescription
140+
node={node}
141+
dataModel={data.dataModel}
142+
triggerObjectTable={dataSharp.computed.triggerObjectTable.value}
143+
/>
144+
);
145+
}
146+
if (isTimeAdd(node)) {
147+
return <Description description={t('scenarios:edit_date.now.description')} />;
148+
}
149+
}
150+
151+
function Description({ description }: { description: string }) {
152+
return description ? (
153+
<p className="text-grey-50 max-w-[300px] text-xs font-normal first-letter:capitalize">
154+
{description}
155+
</p>
156+
) : null;
157+
}
158+
159+
type AggregatorDescriptionProps = {
160+
node: IdLessAstNode<AggregationAstNode>;
161+
};
162+
function AggregatorDescription({ node }: AggregatorDescriptionProps) {
163+
const { aggregator, tableName, fieldName, filters } = node.namedChildren;
164+
if (!tableName.constant && !fieldName.constant && filters.children.length === 0) return null;
165+
166+
const aggregatedFieldName = `${tableName.constant}.${fieldName.constant}`;
167+
168+
return (
169+
<div className="grid grid-cols-[min-content_1fr] items-center gap-2">
170+
<span className="text-purple-65 text-center font-bold">{aggregator.constant}</span>
171+
<span className="font-bold">{aggregatedFieldName}</span>
172+
{filters.children.map((filter, index) => {
173+
const { operator, fieldName } = filter.namedChildren;
174+
return (
175+
<Fragment key={`filter_${index}`}>
176+
<LogicalOperatorLabel operator={index === 0 ? 'where' : 'and'} type="text" />
177+
<div className="flex items-center gap-1">
178+
{/* TODO: replace with OperandLabel for consistency,
179+
we may need to change the AggregatorEditableAstNode to register a valid Payload node (instead of the shorthand Constant)
180+
but it can be cumbersome for api compatibility (notably when getting the astNode from the server)
181+
182+
Should be stringified as a "payload access" with :
183+
- a field name (string) = fieldName?.constant
184+
- a table name (string) = tableName?.constant
185+
*/}
186+
<p className="bg-grey-98 whitespace-nowrap p-2 text-end">
187+
{fieldName.constant ?? '...'}
188+
</p>
189+
<ViewingOperator operator={operator.constant} />
190+
{isBinaryAggregationFilter(filter) ? (
191+
<ViewingAstBuilderOperand node={filter.namedChildren.value} />
192+
) : null}
193+
</div>
194+
</Fragment>
195+
);
196+
})}
197+
</div>
198+
);
199+
}
200+
201+
type CustomListAccessDescriptionProps = {
202+
node: IdLessAstNode<CustomListAccessAstNode>;
203+
customLists: CustomList[];
204+
};
205+
function CustomListAccessDescription({ node, customLists }: CustomListAccessDescriptionProps) {
206+
const customList = customLists.find(
207+
(list) => list.id === node.namedChildren.customListId.constant,
208+
);
209+
if (!customList) return null;
210+
211+
return <Description description={customList.description} />;
212+
}
213+
214+
type DataAccessorDescriptionProps = {
215+
node: IdLessAstNode<DataAccessorAstNode>;
216+
dataModel: DataModel;
217+
triggerObjectTable: TableModel;
218+
};
219+
function DataAccessorDescription({
220+
node,
221+
dataModel,
222+
triggerObjectTable,
223+
}: DataAccessorDescriptionProps) {
224+
const { t } = useTranslation(['scenarios']);
225+
const field = getDataAccessorAstNodeField(node, { triggerObjectTable, dataModel });
226+
227+
return (
228+
<>
229+
<Description description={field.description} />
230+
{field.isEnum && field.values && field.values.length > 0 ? (
231+
<div className="text-grey-50 flex max-w-[300px] flex-col gap-1">
232+
<p className="text-s">{t('scenarios:enum_options')}</p>
233+
<ul className="flex flex-col">
234+
{field.values
235+
.slice(0, MAX_ENUM_VALUES)
236+
.sort()
237+
.map((value) => (
238+
<li key={value} className="truncate text-xs font-normal">
239+
{value}
240+
</li>
241+
))}
242+
{field.values.length > MAX_ENUM_VALUES ? (
243+
<li className="text-xs font-normal">...</li>
244+
) : null}
245+
</ul>
246+
</div>
247+
) : null}
248+
</>
249+
);
250+
}

0 commit comments

Comments
 (0)