feat: uniformize metadata store with flat types, SSE alignment, presentation endpoint & localStorage (#18647)
## Summary
Uniformizes the metadata store to support **all** backend flat metadata
types, introduces a **minimal metadata endpoint** for fast initial
renders, replaces custom localStorage persistence with **Jotai's
built-in `atomWithStorage`**, and wires up a
**MinimalMetadataLoadEffect** for stale-while-revalidate loading.
### Key changes
- **All flat metadata types**: Added `FlatCommandMenuItem`,
`FlatFrontComponent`, `FlatWebhook`, `FlatRole`, `FlatRoleTarget`,
`FlatAgent`, `FlatSkill`, `FlatRowLevelPermissionPredicate`,
`FlatRowLevelPermissionPredicateGroup` — every entity in the backend
`MetadataEntityTypeMap` now has a corresponding frontend flat type
registered in `ALL_METADATA_ENTITY_KEYS` and `MetadataEntityTypeMap`.
- **Minimal metadata endpoint** (`minimalMetadata` GraphQL query): New
backend module (`MinimalMetadataModule`) returns lightweight object
metadata (names, icons, labels, flags) and basic views (id, type, key,
objectMetadataId) plus a `metadataVersion`. This enables fast first
paint before full metadata loads.
- **Jotai `atomWithStorage` for persistence**: Replaced the custom
`MetadataLocalStorageEffect` with Jotai's built-in `atomWithStorage` on
both `metadataStoreState` (family) and `metadataVersionState`. Added
`localStorageOptions` support to `createAtomFamilyState` for `{
getOnInit: true }` synchronous hydration. Each entity atom auto-persists
under keys like `metadataStoreState__objectMetadataItems`.
- **MinimalMetadataLoadEffect**: New effect mounted before
`MetadataProviderInitialEffects` that checks if the store already has
data (from Jotai localStorage hydration). If empty, it fetches minimal
metadata from the new endpoint. The full metadata load continues in
parallel, eventually enriching the store with complete data.
- **SSE effects alignment**: All metadata entity types now have
corresponding SSE effects that directly patch the metadata store via
`patchMetadataStoreFromSSEEvent`.
- **Existing selectors and joining logic**:
`objectMetadataItemsWithFieldsSelector`, `viewsWithRelationsSelector`,
`pageLayoutsWithRelationsSelector` reconstruct nested data from flat
entities for components that need it.
### Loading flow
```
App mount
→ Jotai atomWithStorage hydrates store from localStorage (sync, getOnInit)
→ MinimalMetadataLoadEffect
→ Store has data? → skip (app renders immediately)
→ Store empty? → fetch minimalMetadata endpoint → populate objects + views
→ MetadataProviderInitialEffects (full metadata load, runs in parallel)
→ LazyMetadataLoadEffect (page layouts, logic functions, nav menu, etc.)
→ IsAppMetadataReadyEffect (sets isAppMetadataReady)
```
## Test plan
- [ ] Verify app loads with empty localStorage (should fetch minimal
metadata, then full)
- [ ] Verify app loads with populated localStorage (should skip minimal
fetch, render immediately)
- [ ] Verify SSE events correctly update metadata store for all entity
types
- [ ] Verify logout clears metadata store (atom reset propagates to
localStorage)
- [ ] Verify all metadata selectors return correct joined data
- [ ] CI: lint, typecheck, tests pass
This commit is contained in:
+60
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
metadataStoreState,
|
||||
type MetadataEntityKey,
|
||||
} from '@/metadata-store/states/metadataStoreState';
|
||||
import { type createStore } from 'jotai';
|
||||
|
||||
type JotaiStore = ReturnType<typeof createStore>;
|
||||
|
||||
type SSEEventOperation =
|
||||
| {
|
||||
type: 'create';
|
||||
createdRecord: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: 'update';
|
||||
updatedRecord: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: 'delete';
|
||||
deletedRecordId: string;
|
||||
};
|
||||
|
||||
export const patchMetadataStoreFromSSEEvent = (
|
||||
store: JotaiStore,
|
||||
entityKey: MetadataEntityKey,
|
||||
operation: SSEEventOperation,
|
||||
) => {
|
||||
const entry = store.get(metadataStoreState.atomFamily(entityKey));
|
||||
const currentItems = entry.current as Array<{ id: string }>;
|
||||
|
||||
switch (operation.type) {
|
||||
case 'create': {
|
||||
store.set(metadataStoreState.atomFamily(entityKey), {
|
||||
...entry,
|
||||
current: [...currentItems, operation.createdRecord],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
store.set(metadataStoreState.atomFamily(entityKey), {
|
||||
...entry,
|
||||
current: currentItems.map((item) =>
|
||||
item.id === (operation.updatedRecord as { id: string }).id
|
||||
? { ...item, ...operation.updatedRecord }
|
||||
: item,
|
||||
),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
store.set(metadataStoreState.atomFamily(entityKey), {
|
||||
...entry,
|
||||
current: currentItems.filter(
|
||||
(item) => item.id !== operation.deletedRecordId,
|
||||
),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { type FlatPageLayout } from '@/metadata-store/types/FlatPageLayout';
|
||||
import { type FlatPageLayoutTab } from '@/metadata-store/types/FlatPageLayoutTab';
|
||||
import { type FlatPageLayoutWidget } from '@/metadata-store/types/FlatPageLayoutWidget';
|
||||
import { type PageLayout } from '@/page-layout/types/PageLayout';
|
||||
|
||||
type SplitResult = {
|
||||
flatPageLayouts: FlatPageLayout[];
|
||||
flatPageLayoutTabs: FlatPageLayoutTab[];
|
||||
flatPageLayoutWidgets: FlatPageLayoutWidget[];
|
||||
};
|
||||
|
||||
export const splitPageLayoutWithRelated = (
|
||||
pageLayouts: PageLayout[],
|
||||
): SplitResult => {
|
||||
const flatPageLayouts: FlatPageLayout[] = [];
|
||||
const flatPageLayoutTabs: FlatPageLayoutTab[] = [];
|
||||
const flatPageLayoutWidgets: FlatPageLayoutWidget[] = [];
|
||||
|
||||
for (const pageLayout of pageLayouts) {
|
||||
const { tabs = [], ...pageLayoutProperties } = pageLayout;
|
||||
|
||||
flatPageLayouts.push(pageLayoutProperties);
|
||||
|
||||
for (const tab of tabs) {
|
||||
const { widgets = [], ...tabProperties } = tab;
|
||||
|
||||
flatPageLayoutTabs.push({
|
||||
...tabProperties,
|
||||
pageLayoutId: pageLayout.id,
|
||||
});
|
||||
|
||||
for (const widget of widgets) {
|
||||
flatPageLayoutWidgets.push({
|
||||
...widget,
|
||||
pageLayoutTabId: tab.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
flatPageLayouts,
|
||||
flatPageLayoutTabs,
|
||||
flatPageLayoutWidgets,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { type FlatView } from '@/metadata-store/types/FlatView';
|
||||
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
|
||||
import { type FlatViewFieldGroup } from '@/metadata-store/types/FlatViewFieldGroup';
|
||||
import { type FlatViewFilter } from '@/metadata-store/types/FlatViewFilter';
|
||||
import { type FlatViewFilterGroup } from '@/metadata-store/types/FlatViewFilterGroup';
|
||||
import { type FlatViewGroup } from '@/metadata-store/types/FlatViewGroup';
|
||||
import { type FlatViewSort } from '@/metadata-store/types/FlatViewSort';
|
||||
import { type CoreViewWithRelations } from '@/views/types/CoreViewWithRelations';
|
||||
|
||||
type SplitResult = {
|
||||
flatViews: FlatView[];
|
||||
flatViewFields: FlatViewField[];
|
||||
flatViewFilters: FlatViewFilter[];
|
||||
flatViewSorts: FlatViewSort[];
|
||||
flatViewGroups: FlatViewGroup[];
|
||||
flatViewFilterGroups: FlatViewFilterGroup[];
|
||||
flatViewFieldGroups: FlatViewFieldGroup[];
|
||||
};
|
||||
|
||||
export const splitViewWithRelated = (
|
||||
viewsWithRelated: CoreViewWithRelations[],
|
||||
): SplitResult => {
|
||||
const flatViews: FlatView[] = [];
|
||||
const flatViewFields: FlatViewField[] = [];
|
||||
const flatViewFilters: FlatViewFilter[] = [];
|
||||
const flatViewSorts: FlatViewSort[] = [];
|
||||
const flatViewGroups: FlatViewGroup[] = [];
|
||||
const flatViewFilterGroups: FlatViewFilterGroup[] = [];
|
||||
const flatViewFieldGroups: FlatViewFieldGroup[] = [];
|
||||
|
||||
for (const viewWithRelated of viewsWithRelated) {
|
||||
const {
|
||||
viewFields = [],
|
||||
viewFilters = [],
|
||||
viewSorts = [],
|
||||
viewGroups = [],
|
||||
viewFilterGroups = [],
|
||||
viewFieldGroups = [],
|
||||
...viewProperties
|
||||
} = viewWithRelated;
|
||||
|
||||
flatViews.push(viewProperties);
|
||||
|
||||
for (const viewField of viewFields) {
|
||||
flatViewFields.push({
|
||||
...viewField,
|
||||
viewId: viewWithRelated.id,
|
||||
});
|
||||
}
|
||||
|
||||
for (const viewFilter of viewFilters) {
|
||||
flatViewFilters.push({
|
||||
...viewFilter,
|
||||
viewId: viewWithRelated.id,
|
||||
});
|
||||
}
|
||||
|
||||
for (const viewSort of viewSorts) {
|
||||
flatViewSorts.push({
|
||||
...viewSort,
|
||||
viewId: viewWithRelated.id,
|
||||
});
|
||||
}
|
||||
|
||||
for (const viewGroup of viewGroups) {
|
||||
flatViewGroups.push({
|
||||
...viewGroup,
|
||||
viewId: viewWithRelated.id,
|
||||
});
|
||||
}
|
||||
|
||||
for (const viewFilterGroup of viewFilterGroups) {
|
||||
flatViewFilterGroups.push(viewFilterGroup);
|
||||
}
|
||||
|
||||
for (const viewFieldGroup of viewFieldGroups) {
|
||||
const { viewFields: _viewFields, ...viewFieldGroupProperties } =
|
||||
viewFieldGroup;
|
||||
|
||||
flatViewFieldGroups.push(viewFieldGroupProperties);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
flatViews,
|
||||
flatViewFields,
|
||||
flatViewFilters,
|
||||
flatViewSorts,
|
||||
flatViewGroups,
|
||||
flatViewFilterGroups,
|
||||
flatViewFieldGroups,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user