diff --git a/packages/app-builder/src/components/Graph/GraphRelationsSettings.tsx b/packages/app-builder/src/components/Graph/GraphRelationsSettings.tsx index 518e12f7e..af09746c0 100644 --- a/packages/app-builder/src/components/Graph/GraphRelationsSettings.tsx +++ b/packages/app-builder/src/components/Graph/GraphRelationsSettings.tsx @@ -61,32 +61,31 @@ function joinableFields(rightTable: TableModel | undefined, leftField: DataModel return rightTable.fields.filter((field) => areFieldsJoinable(leftField, field)); } -/** The relations sharing one label, which is what the settings UI calls a "setting". */ -type RelationGroup = { label: string; relations: GraphRelation[] }; +/** The relations sharing one server-side group id, which is what the settings UI calls a "setting". */ +type RelationGroup = { id: string; label: string; relations: GraphRelation[] }; const relationGroupColumnHelper = createColumnHelper(); -function groupRelationsByLabel(relations: GraphRelation[]): RelationGroup[] { - const groups = new Map(); +function groupRelationsByGroupId(relations: GraphRelation[]): RelationGroup[] { + const groups = new Map(); for (const relation of relations) { - const existing = groups.get(relation.label); + const existing = groups.get(relation.groupId); if (existing) { - existing.push(relation); + existing.relations.push(relation); } else { - groups.set(relation.label, [relation]); + groups.set(relation.groupId, { id: relation.groupId, label: relation.label, relations: [relation] }); } } - return [...groups.entries()].map(([label, grouped]) => ({ label, relations: grouped })); + return [...groups.values()]; } +/** `relations` is already scoped to one group, so only the endpoints are compared. */ function isDuplicateRelation( relations: GraphRelation[], - label: string, candidate: Pick, ): boolean { return relations.some( (relation) => - relation.label === label && relation.leftType === candidate.leftType && relation.leftField === candidate.leftField && relation.rightType === candidate.rightType && @@ -103,11 +102,10 @@ const RELATION_SCOPE_KEYS = { 'cross-table': 'graph:settings.scope.cross_table', } as const satisfies Record; -const createLabelSchema = z.object({ - label: z.string().trim().min(1), -}); - -const relationFormSchema = createGraphRelationPayloadSchema.omit({ label: true }); +/** One panel form covering the group label and the relation being added. */ +const relationFormSchema = createGraphRelationPayloadSchema + .omit({ groupId: true }) + .extend({ label: z.string().trim().min(1) }); function TableFieldSelect({ label, @@ -172,85 +170,6 @@ function RelationEndpoints({ relation }: { relation: GraphRelation }) { ); } -function CreateLabelModal({ - open, - onOpenChange, - onSubmitLabel, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - onSubmitLabel: (label: string) => void; -}) { - const { t } = useTranslation(graphI18n); - const form = useForm({ - defaultValues: { label: '' }, - validators: { onSubmit: createLabelSchema }, - onSubmit: ({ value, formApi }) => { - if (!formApi.state.isValid) return; - const label = value.label.trim(); - formApi.reset(); - onOpenChange(false); - onSubmitLabel(label); - }, - }); - - return ( - { - if (!next) form.reset(); - onOpenChange(next); - }} - > - -
- {t('graph:settings.create_label.title')} -
- - {(field) => ( -
- - {t('graph:settings.create_label.field')} - - field.handleChange(event.currentTarget.value)} - onBlur={field.handleBlur} - borderColor={field.state.meta.errors.length === 0 ? 'greyfigma-90' : 'redfigma-47'} - placeholder={t('graph:settings.create_label.placeholder')} - /> - -
- )} -
-
- - - createLabelSchema.safeParse(state.values).success}> - {(isValid) => ( - - )} - - -
-
-
- ); -} - function DeleteSettingModal({ group, onOpenChange, @@ -294,12 +213,20 @@ function RelationSettingPanel({ open, onOpenChange, label, + groupId, + onGroupCreated, + onGroupEmptied, dataModel, relations, }: { open: boolean; onOpenChange: (open: boolean) => void; + /** Empty for a group that does not exist yet, where the user still has to name it. */ label: string; + /** `null` until the first relation is created: the API then mints the group id. */ + groupId: string | null; + onGroupCreated: (group: { id: string; label: string }) => void; + onGroupEmptied: () => void; dataModel: DataModel; relations: GraphRelation[]; }) { @@ -309,14 +236,18 @@ function RelationSettingPanel({ const [scope, setScope] = useState('same-table'); const form = useForm({ - defaultValues: { leftType: '', leftField: '', rightType: '', rightField: '' }, + defaultValues: { label, leftType: '', leftField: '', rightType: '', rightField: '' }, validators: { onSubmit: relationFormSchema }, onSubmit: ({ value, formApi }) => { - if (!formApi.state.isValid || isDuplicateRelation(relations, label, value)) return; + if (!formApi.state.isValid || isDuplicateRelation(relations, value)) return; + const trimmedLabel = value.label.trim(); createMutation.mutate( - { ...value, label }, + // No group id yet means "create a new group"; the response tells us which one it became. + { ...value, label: trimmedLabel, groupId: groupId ?? undefined }, { - onSuccess: () => { + onSuccess: (created) => { + if (!groupId) onGroupCreated({ id: created.groupId, label: created.label }); + formApi.setFieldValue('label', trimmedLabel); formApi.setFieldValue('leftField', ''); formApi.setFieldValue('rightField', ''); }, @@ -325,6 +256,8 @@ function RelationSettingPanel({ }, }); + // The label belongs to the group, so it is frozen as soon as the group exists. + const isLabelLocked = groupId !== null; const isSelfRelation = scope === 'same-table'; const onScopeChange = (next: RelationScope) => { @@ -356,82 +289,112 @@ function RelationSettingPanel({ - {label} + {label || t('graph:settings.create_label.title')}
-
-

{t('graph:settings.add_relation')}

- t(RELATION_SCOPE_KEYS[value]))} - onChange={onScopeChange} - /> - - state.values}> - {({ leftType, leftField, rightType, rightField }) => { - const leftFieldOptions = semanticFields(dataModel.find((table) => table.name === leftType)); - const selectedLeftField = leftFieldOptions.find((field) => field.name === leftField); - - return ( - <> - - - table.name === rightType), - selectedLeftField, - )} - onTableChange={(name) => { - form.setFieldValue('rightType', name); - form.setFieldValue('rightField', ''); - }} - onFieldChange={(name) => { - form.setFieldValue('rightField', name); - }} - tableDisabled={isSelfRelation} - disabled={!selectedLeftField} - /> - - ); - }} - - - state.values}> - {(values) => { - const isComplete = relationFormSchema.safeParse(values).success; - const isDuplicate = isComplete && isDuplicateRelation(relations, label, values); - - return ( - <> - {isDuplicate ?

{t('graph:settings.duplicate')}

: null} -
- -
- - ); + + + > + {(field) => ( +
+ + {t('graph:settings.create_label.field')} + + field.handleChange(event.currentTarget.value)} + onBlur={field.handleBlur} + borderColor={field.state.meta.errors.length === 0 ? 'greyfigma-90' : 'redfigma-47'} + placeholder={t('graph:settings.create_label.placeholder')} + disabled={isLabelLocked} + // Naming the setting is the first step of a creation, so start there. + autoFocus={!isLabelLocked} + /> + +
+ )} +
+ +
+

{t('graph:settings.add_relation')}

+ t(RELATION_SCOPE_KEYS[value]))} + onChange={onScopeChange} + /> + + state.values}> + {({ leftType, leftField, rightType, rightField }) => { + const leftFieldOptions = semanticFields(dataModel.find((table) => table.name === leftType)); + const selectedLeftField = leftFieldOptions.find((field) => field.name === leftField); + + return ( + <> + + + table.name === rightType), + selectedLeftField, + )} + onTableChange={(name) => { + form.setFieldValue('rightType', name); + form.setFieldValue('rightField', ''); + }} + onFieldChange={(name) => { + form.setFieldValue('rightField', name); + }} + tableDisabled={isSelfRelation} + disabled={!selectedLeftField} + /> + + ); + }} + + + state.values}> + {(values) => { + const isComplete = relationFormSchema.safeParse(values).success; + const isDuplicate = isComplete && isDuplicateRelation(relations, values); + + return ( + <> + {isDuplicate ? ( +

{t('graph:settings.duplicate')}

+ ) : null} +
+ +
+ + ); + }} +
+
@@ -452,7 +415,18 @@ function RelationSettingPanel({ mode="icon" aria-label={t('graph:settings.delete_relation')} disabled={deleteMutation.isPending} - onClick={() => deleteMutation.mutate({ relationId: relation.id })} + onClick={() => + deleteMutation.mutate( + { relationId: relation.id }, + { + // Removing the last relation removes the group itself: forget its id so + // the next relation starts a new group instead of a dead one. + onSuccess: () => { + if (relations.length === 1) onGroupEmptied(); + }, + }, + ) + } > @@ -471,24 +445,33 @@ function RelationSettingPanel({ ); } +/** + * The setting the panel is editing. `groupId` is `null` while a brand new group has no relation yet + * (its label is then still empty and editable); `key` stays stable across that transition so filling + * it in does not remount (and reset) the panel. + */ +type PanelTarget = { key: string; label: string; groupId: string | null }; + +const NEW_GROUP_TARGET: PanelTarget = { key: 'new-group', label: '', groupId: null }; + export function GraphRelationsSettings({ dataModel }: { dataModel: DataModel }) { const { t } = useTranslation(graphI18n); const relationsQuery = useListGraphRelationsQuery(); const deleteRelationsMutation = useDeleteGraphRelationsMutation(); - const [createModalOpen, setCreateModalOpen] = useState(false); - const [panelLabel, setPanelLabel] = useState(null); + const [panelTarget, setPanelTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const groups = useMemo( - () => (relationsQuery.data ? groupRelationsByLabel(relationsQuery.data) : []), + () => (relationsQuery.data ? groupRelationsByGroupId(relationsQuery.data) : []), [relationsQuery.data], ); const panelRelations = useMemo(() => { - if (!panelLabel || !relationsQuery.data) return []; - return relationsQuery.data.filter((relation) => relation.label === panelLabel); - }, [panelLabel, relationsQuery.data]); + const groupId = panelTarget?.groupId; + if (!groupId || !relationsQuery.data) return []; + return relationsQuery.data.filter((relation) => relation.groupId === groupId); + }, [panelTarget?.groupId, relationsQuery.data]); const columns = useMemo( () => [ @@ -531,7 +514,7 @@ export function GraphRelationsSettings({ dataModel }: { dataModel: DataModel }) appearance="stroked" mode="icon" aria-label={t('common:edit')} - onClick={() => setPanelLabel(group.label)} + onClick={() => setPanelTarget({ key: group.id, label: group.label, groupId: group.id })} > @@ -559,6 +542,7 @@ export function GraphRelationsSettings({ dataModel }: { dataModel: DataModel }) const { table, getBodyProps, rows, getContainerProps } = useTable({ data: groups, columns, + getRowId: (row) => row.id, columnResizeMode: 'onChange', getCoreRowModel: getCoreRowModel(), enableSorting: false, @@ -570,7 +554,7 @@ export function GraphRelationsSettings({ dataModel }: { dataModel: DataModel }) group.relations.map((relation) => relation.id), { onSuccess: () => { - if (panelLabel === group.label) setPanelLabel(null); + if (panelTarget?.groupId === group.id) setPanelTarget(null); setDeleteTarget(null); }, }, @@ -585,7 +569,7 @@ export function GraphRelationsSettings({ dataModel }: { dataModel: DataModel })