Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Standardized List Refresh After Mutation (P0/P1/P2)** (`@object-ui/types`, `@object-ui/plugin-list`, `@object-ui/plugin-view`, `@object-ui/plugin-kanban`, `@object-ui/plugin-calendar`, `@object-ui/react`, `@object-ui/core`, `apps/console`): Resolved a platform-level architectural deficiency where list views did not refresh after create/update/delete mutations. The fix spans three phases:
- **P0 — refreshTrigger Prop**: Added `refreshTrigger?: number` to `ListViewSchema`. When a parent component (e.g., `ObjectView`) increments this value after a mutation, `ListView` automatically re-fetches data. The plugin-view's `ObjectView.renderContent()` now passes its internal `refreshKey` as both a direct callback prop and embedded in the schema's `refreshTrigger`. The console `ObjectView` combines both its own and the plugin's refresh signals for full propagation.
- **P1 — Imperative `refresh()` API + `useDataRefresh` hook**: `ListView` is now wrapped with `React.forwardRef` and exposes a `refresh()` method via `useImperativeHandle`. Parents can trigger a re-fetch programmatically via `listRef.current?.refresh()`. Exported `ListViewHandle` type from `@object-ui/plugin-list`. Added reusable `useDataRefresh(dataSource, objectName)` hook to `@object-ui/react` that encapsulates the refreshKey state + `onMutation` subscription pattern for any view component.
- **P2 — DataSource Mutation Event Bus**: Added `MutationEvent` interface and optional `onMutation(callback): unsubscribe` method to the `DataSource` interface. All data-bound views now auto-subscribe to mutation events when the DataSource implements this: `ListView`, `ObjectView` (plugin-view), `ObjectKanban` (plugin-kanban), and `ObjectCalendar` (plugin-calendar). `ValueDataSource` emits mutation events on create/update/delete. Includes 22 new tests covering all three phases.

- **Unified i18n Plugin Loading & Translation Injection** (`examples/crm`, `apps/console`): Unified the i18n loading mechanism so that both server and MSW/mock environments use the same translation pipeline. CRM's `objectstack.config.ts` now declares its translations via `i18n: { namespace: 'crm', translations: crmLocales }`. The shared config (`objectstack.shared.ts`) merges i18n bundles from all composed stacks. `createKernel` registers an i18n kernel service from the config bundles and auto-generates the `/api/v1/i18n/translations/:lang` MSW handler, returning translations in the standard `{ data: { locale, translations } }` spec envelope. Removed all manually-maintained i18n custom handlers and duplicate `loadAppLocale` functions from `browser.ts` and `server.ts`. The broker shim now supports `i18n.getTranslations` for server-side dispatch.

- **ObjectDataTable: columns now support `string[]` shorthand** (`@object-ui/plugin-dashboard`): `ObjectDataTable` now normalizes `columns` entries so that both `string[]` (e.g. `['name', 'close_date']`) and `object[]` formats are accepted. String entries are automatically converted to `{ header, accessorKey }` objects with title-cased headers derived from snake_case and camelCase field names. Previously, passing a `string[]` caused the downstream `data-table` renderer to crash when accessing `col.accessorKey` on a plain string. Mixed arrays (some strings, some objects) are also handled correctly. Includes 8 new unit tests.
Expand Down
6 changes: 4 additions & 2 deletions apps/console/src/components/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -532,8 +532,10 @@ export function ObjectView({ dataSource, objects, onEdit }: any) {
}, [drawerRecordId]);

// Render multi-view content via ListView plugin (for kanban, calendar, etc.)
const renderListView = useCallback(({ schema: listSchema, dataSource: ds, onEdit: editHandler, className }: any) => {
const key = `${objectName}-${activeView.id}-${refreshKey}`;
const renderListView = useCallback(({ schema: listSchema, dataSource: ds, onEdit: editHandler, className, refreshKey: pluginRefreshKey }: any) => {
// Combine local refreshKey with the plugin ObjectView's refreshKey for full propagation
const combinedRefreshKey = refreshKey + (pluginRefreshKey || 0);
const key = `${objectName}-${activeView.id}-${combinedRefreshKey}`;
const viewDef = activeView;

// Warn in dev mode if flat properties are used instead of nested spec format
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/adapters/ValueDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import type {
DataSource,
MutationEvent,
QueryParams,
QueryResult,
AggregateParams,
Expand Down Expand Up @@ -228,13 +229,21 @@ function selectFields<T>(record: T, fields?: string[]): T {
export class ValueDataSource<T = any> implements DataSource<T> {
private items: T[];
private idField: string | undefined;
private mutationListeners = new Set<(event: MutationEvent<T>) => void>();

constructor(config: ValueDataSourceConfig<T>) {
// Deep clone to prevent external mutation
this.items = JSON.parse(JSON.stringify(config.items));
this.idField = config.idField;
}

/** Notify all mutation subscribers */
private emitMutation(event: MutationEvent<T>): void {
for (const listener of this.mutationListeners) {
try { listener(event); } catch (err) { console.warn('ValueDataSource: mutation listener error', err); }
}
}

// -----------------------------------------------------------------------
// DataSource interface
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -308,6 +317,7 @@ export class ValueDataSource<T = any> implements DataSource<T> {
(record as any)[field] = `auto_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
this.items.push(record);
this.emitMutation({ type: 'create', resource: _resource, record: { ...record } });
return { ...record };
}

Expand All @@ -323,6 +333,7 @@ export class ValueDataSource<T = any> implements DataSource<T> {
throw new Error(`ValueDataSource: Record with id "${id}" not found`);
}
this.items[index] = { ...this.items[index], ...data };
this.emitMutation({ type: 'update', resource: _resource, id, record: { ...this.items[index] } });
return { ...this.items[index] };
}

Expand All @@ -332,6 +343,7 @@ export class ValueDataSource<T = any> implements DataSource<T> {
);
if (index === -1) return false;
this.items.splice(index, 1);
this.emitMutation({ type: 'delete', resource: _resource, id });
return true;
}

Expand Down Expand Up @@ -422,6 +434,15 @@ export class ValueDataSource<T = any> implements DataSource<T> {
});
}

// -----------------------------------------------------------------------
// Mutation subscription (P2 — Event Bus)
// -----------------------------------------------------------------------

onMutation(callback: (event: MutationEvent<T>) => void): () => void {
this.mutationListeners.add(callback);
return () => { this.mutationListeners.delete(callback); };
}

// -----------------------------------------------------------------------
// Extra utilities
// -----------------------------------------------------------------------
Expand Down
99 changes: 99 additions & 0 deletions packages/core/src/adapters/__tests__/ValueDataSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,3 +470,102 @@ describe('ValueDataSource — aggregate', () => {
expect(result.find((r: any) => r.category === 'B')?.amount).toBe(50);
});
});

// ---------------------------------------------------------------------------
// onMutation (P2 — Event Bus)
// ---------------------------------------------------------------------------

describe('ValueDataSource — onMutation', () => {
it('should emit "create" event when a record is created', async () => {
const ds = createDS();
const events: any[] = [];
ds.onMutation((e) => events.push(e));

await ds.create('users', { name: 'Zara', age: 40 });

expect(events).toHaveLength(1);
expect(events[0].type).toBe('create');
expect(events[0].resource).toBe('users');
expect(events[0].record.name).toBe('Zara');
});

it('should emit "update" event when a record is updated', async () => {
const ds = createDS();
const events: any[] = [];
ds.onMutation((e) => events.push(e));

await ds.update('users', '1', { age: 31 });

expect(events).toHaveLength(1);
expect(events[0].type).toBe('update');
expect(events[0].resource).toBe('users');
expect(events[0].id).toBe('1');
expect(events[0].record.age).toBe(31);
});

it('should emit "delete" event when a record is deleted', async () => {
const ds = createDS();
const events: any[] = [];
ds.onMutation((e) => events.push(e));

await ds.delete('users', '2');

expect(events).toHaveLength(1);
expect(events[0].type).toBe('delete');
expect(events[0].resource).toBe('users');
expect(events[0].id).toBe('2');
expect(events[0].record).toBeUndefined();
});

it('should not emit "delete" for non-existent record', async () => {
const ds = createDS();
const events: any[] = [];
ds.onMutation((e) => events.push(e));

await ds.delete('users', '999');

expect(events).toHaveLength(0);
});

it('should support multiple subscribers', async () => {
const ds = createDS();
const eventsA: any[] = [];
const eventsB: any[] = [];
ds.onMutation((e) => eventsA.push(e));
ds.onMutation((e) => eventsB.push(e));

await ds.create('users', { name: 'Multi' });

expect(eventsA).toHaveLength(1);
expect(eventsB).toHaveLength(1);
});

it('should unsubscribe correctly', async () => {
const ds = createDS();
const events: any[] = [];
const unsub = ds.onMutation((e) => events.push(e));

await ds.create('users', { name: 'Before' });
expect(events).toHaveLength(1);

unsub();

await ds.create('users', { name: 'After' });
expect(events).toHaveLength(1); // No new event
});

it('should emit events for bulk operations', async () => {
const ds = createDS();
const events: any[] = [];
ds.onMutation((e) => events.push(e));

await ds.bulk!('users', 'create', [
{ name: 'Bulk1' },
{ name: 'Bulk2' },
]);

// Bulk create calls create() for each item
expect(events).toHaveLength(2);
expect(events.every((e: any) => e.type === 'create')).toBe(true);
});
});
13 changes: 13 additions & 0 deletions packages/plugin-calendar/src/ObjectCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,19 @@ export const ObjectCalendar: React.FC<ObjectCalendarProps> = ({
const [view, setView] = useState<'month' | 'week' | 'day'>('month');
const [refreshKey, setRefreshKey] = useState(0);

// P2: Auto-subscribe to DataSource mutation events (standalone mode only).
// When rendered as a child of ObjectView with external data, parent handles refresh.
useEffect(() => {
if (hasExternalData) return; // Parent handles refresh
if (!dataSource?.onMutation || !schema.objectName) return;
const unsub = dataSource.onMutation((event: any) => {
if (event.resource === schema.objectName) {
setRefreshKey(k => k + 1);
}
});
return unsub;
}, [dataSource, schema.objectName, hasExternalData]);

const handlePullRefresh = useCallback(async () => {
setRefreshKey(k => k + 1);
}, []);
Expand Down
16 changes: 15 additions & 1 deletion packages/plugin-kanban/src/ObjectKanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,24 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
// loading state
const [loading, setLoading] = useState(hasExternalData ? (externalLoading ?? false) : false);
const [error, setError] = useState<Error | null>(null);
const [refreshKey, setRefreshKey] = useState(0);

// Resolve bound data if 'bind' property exists
const boundData = useDataScope(schema.bind);

// P2: Auto-subscribe to DataSource mutation events (standalone mode only).
// When rendered as a child of ListView, data is managed externally and this is skipped.
useEffect(() => {
if (hasExternalData) return; // Parent handles refresh
if (!dataSource?.onMutation || !schema.objectName) return;
const unsub = dataSource.onMutation((event: any) => {
if (event.resource === schema.objectName) {
setRefreshKey(k => k + 1);
}
});
return unsub;
}, [dataSource, schema.objectName, hasExternalData]);

// Sync external data changes from parent (e.g. ListView re-fetches after filter change)
useEffect(() => {
if (hasExternalData && externalLoading !== undefined) {
Expand Down Expand Up @@ -109,7 +123,7 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
fetchData();
}
return () => { isMounted = false; };
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, hasExternalData, objectDef]);
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, hasExternalData, objectDef, refreshKey]);

// Determine which data to use: external -> bound -> inline -> fetched
const rawData = (hasExternalData ? externalData : undefined) || boundData || schema.data || fetchedData;
Expand Down
43 changes: 39 additions & 4 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,24 @@ function useListFieldLabel() {
}
}

export const ListView: React.FC<ListViewProps> = ({
/**
* Imperative handle exposed by ListView via React.forwardRef.
* Allows parent components to trigger a data refresh programmatically.
*
* @example
* ```tsx
* const listRef = React.useRef<ListViewHandle>(null);
* <ListView ref={listRef} schema={schema} />
* // After a mutation:
* listRef.current?.refresh();
* ```
*/
export interface ListViewHandle {
/** Force the ListView to re-fetch data from the DataSource */
refresh(): void;
}

export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
schema: propSchema,
className,
onViewChange,
Expand All @@ -283,7 +300,7 @@ export const ListView: React.FC<ListViewProps> = ({
onRowClick,
showViewSwitcher = false,
...props
}) => {
}, ref) => {
// i18n support for record count and other labels
const { t } = useListViewTranslation();
const { fieldLabel: resolveFieldLabel } = useListFieldLabel();
Expand Down Expand Up @@ -393,6 +410,22 @@ export const ListView: React.FC<ListViewProps> = ({
const [refreshKey, setRefreshKey] = React.useState(0);
const [dataLimitReached, setDataLimitReached] = React.useState(false);

// --- P1: Imperative refresh API ---
React.useImperativeHandle(ref, () => ({
refresh: () => setRefreshKey(k => k + 1),
}), []);

// --- P2: Auto-subscribe to DataSource mutation events ---
React.useEffect(() => {
if (!dataSource?.onMutation || !schema.objectName) return;
const unsub = dataSource.onMutation((event) => {
if (event.resource === schema.objectName) {
setRefreshKey(k => k + 1);
}
});
return unsub;
}, [dataSource, schema.objectName]);
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ListView now supports both schema.refreshTrigger (external refresh) and dataSource.onMutation (internal auto-refresh). In scenarios like ObjectView embedding ListView, a single mutation can change schema.refreshTrigger and fire onMutation, causing two refreshes/two find() calls. Consider skipping the onMutation subscription when schema.refreshTrigger is provided (or otherwise deduping refresh signals) to avoid redundant fetches.

Suggested change
if (!dataSource?.onMutation || !schema.objectName) return;
const unsub = dataSource.onMutation((event) => {
if (event.resource === schema.objectName) {
setRefreshKey(k => k + 1);
}
});
return unsub;
}, [dataSource, schema.objectName]);
// When an external refreshTrigger is provided, rely on that instead of
// subscribing to dataSource mutations to avoid double refreshes.
if (!dataSource?.onMutation || !schema.objectName || schema.refreshTrigger) return;
const unsub = dataSource.onMutation((event) => {
if (event.resource === schema.objectName) {
setRefreshKey(k => k + 1);
}
});
return unsub;
}, [dataSource, schema.objectName, schema.refreshTrigger]);

Copilot uses AI. Check for mistakes.

// Dynamic page size state (wired from pageSizeOptions selector)
const [dynamicPageSize, setDynamicPageSize] = React.useState<number | undefined>(undefined);
const effectivePageSize = dynamicPageSize ?? schema.pagination?.pageSize ?? 100;
Expand Down Expand Up @@ -683,7 +716,7 @@ export const ListView: React.FC<ListViewProps> = ({
fetchData();

return () => { isMounted = false; };
}, [schema.objectName, schema.data, dataSource, schema.filters, effectivePageSize, currentSort, currentFilters, activeQuickFilters, normalizedQuickFilters, userFilterConditions, refreshKey, searchTerm, schema.searchableFields, expandFields, objectDefLoaded]); // Re-fetch on filter/sort/search change
}, [schema.objectName, schema.data, dataSource, schema.filters, effectivePageSize, currentSort, currentFilters, activeQuickFilters, normalizedQuickFilters, userFilterConditions, refreshKey, searchTerm, schema.searchableFields, expandFields, objectDefLoaded, schema.refreshTrigger]); // Re-fetch on filter/sort/search/refreshTrigger change

// Available view types based on schema configuration
const availableViews = React.useMemo(() => {
Expand Down Expand Up @@ -1685,4 +1718,6 @@ export const ListView: React.FC<ListViewProps> = ({
)}
</div>
);
};
});

ListView.displayName = 'ListView';
Loading
Loading