Skip to content

Commit 80c65bd

Browse files
authored
Merge pull request #1127 from objectstack-ai/copilot/standardize-list-refresh-mutations
2 parents 84cd442 + cd2b176 commit 80c65bd

File tree

17 files changed

+722
-10
lines changed

17 files changed

+722
-10
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
### Added
1515

16+
- **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:
17+
- **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.
18+
- **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.
19+
- **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.
20+
1621
- **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.
1722

1823
- **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.

apps/console/src/components/ObjectView.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -532,8 +532,10 @@ export function ObjectView({ dataSource, objects, onEdit }: any) {
532532
}, [drawerRecordId]);
533533

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

539541
// Warn in dev mode if flat properties are used instead of nested spec format

packages/core/src/adapters/ValueDataSource.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import type {
1313
DataSource,
14+
MutationEvent,
1415
QueryParams,
1516
QueryResult,
1617
AggregateParams,
@@ -228,13 +229,21 @@ function selectFields<T>(record: T, fields?: string[]): T {
228229
export class ValueDataSource<T = any> implements DataSource<T> {
229230
private items: T[];
230231
private idField: string | undefined;
232+
private mutationListeners = new Set<(event: MutationEvent<T>) => void>();
231233

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

240+
/** Notify all mutation subscribers */
241+
private emitMutation(event: MutationEvent<T>): void {
242+
for (const listener of this.mutationListeners) {
243+
try { listener(event); } catch (err) { console.warn('ValueDataSource: mutation listener error', err); }
244+
}
245+
}
246+
238247
// -----------------------------------------------------------------------
239248
// DataSource interface
240249
// -----------------------------------------------------------------------
@@ -308,6 +317,7 @@ export class ValueDataSource<T = any> implements DataSource<T> {
308317
(record as any)[field] = `auto_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
309318
}
310319
this.items.push(record);
320+
this.emitMutation({ type: 'create', resource: _resource, record: { ...record } });
311321
return { ...record };
312322
}
313323

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

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

@@ -422,6 +434,15 @@ export class ValueDataSource<T = any> implements DataSource<T> {
422434
});
423435
}
424436

437+
// -----------------------------------------------------------------------
438+
// Mutation subscription (P2 — Event Bus)
439+
// -----------------------------------------------------------------------
440+
441+
onMutation(callback: (event: MutationEvent<T>) => void): () => void {
442+
this.mutationListeners.add(callback);
443+
return () => { this.mutationListeners.delete(callback); };
444+
}
445+
425446
// -----------------------------------------------------------------------
426447
// Extra utilities
427448
// -----------------------------------------------------------------------

packages/core/src/adapters/__tests__/ValueDataSource.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,3 +470,102 @@ describe('ValueDataSource — aggregate', () => {
470470
expect(result.find((r: any) => r.category === 'B')?.amount).toBe(50);
471471
});
472472
});
473+
474+
// ---------------------------------------------------------------------------
475+
// onMutation (P2 — Event Bus)
476+
// ---------------------------------------------------------------------------
477+
478+
describe('ValueDataSource — onMutation', () => {
479+
it('should emit "create" event when a record is created', async () => {
480+
const ds = createDS();
481+
const events: any[] = [];
482+
ds.onMutation((e) => events.push(e));
483+
484+
await ds.create('users', { name: 'Zara', age: 40 });
485+
486+
expect(events).toHaveLength(1);
487+
expect(events[0].type).toBe('create');
488+
expect(events[0].resource).toBe('users');
489+
expect(events[0].record.name).toBe('Zara');
490+
});
491+
492+
it('should emit "update" event when a record is updated', async () => {
493+
const ds = createDS();
494+
const events: any[] = [];
495+
ds.onMutation((e) => events.push(e));
496+
497+
await ds.update('users', '1', { age: 31 });
498+
499+
expect(events).toHaveLength(1);
500+
expect(events[0].type).toBe('update');
501+
expect(events[0].resource).toBe('users');
502+
expect(events[0].id).toBe('1');
503+
expect(events[0].record.age).toBe(31);
504+
});
505+
506+
it('should emit "delete" event when a record is deleted', async () => {
507+
const ds = createDS();
508+
const events: any[] = [];
509+
ds.onMutation((e) => events.push(e));
510+
511+
await ds.delete('users', '2');
512+
513+
expect(events).toHaveLength(1);
514+
expect(events[0].type).toBe('delete');
515+
expect(events[0].resource).toBe('users');
516+
expect(events[0].id).toBe('2');
517+
expect(events[0].record).toBeUndefined();
518+
});
519+
520+
it('should not emit "delete" for non-existent record', async () => {
521+
const ds = createDS();
522+
const events: any[] = [];
523+
ds.onMutation((e) => events.push(e));
524+
525+
await ds.delete('users', '999');
526+
527+
expect(events).toHaveLength(0);
528+
});
529+
530+
it('should support multiple subscribers', async () => {
531+
const ds = createDS();
532+
const eventsA: any[] = [];
533+
const eventsB: any[] = [];
534+
ds.onMutation((e) => eventsA.push(e));
535+
ds.onMutation((e) => eventsB.push(e));
536+
537+
await ds.create('users', { name: 'Multi' });
538+
539+
expect(eventsA).toHaveLength(1);
540+
expect(eventsB).toHaveLength(1);
541+
});
542+
543+
it('should unsubscribe correctly', async () => {
544+
const ds = createDS();
545+
const events: any[] = [];
546+
const unsub = ds.onMutation((e) => events.push(e));
547+
548+
await ds.create('users', { name: 'Before' });
549+
expect(events).toHaveLength(1);
550+
551+
unsub();
552+
553+
await ds.create('users', { name: 'After' });
554+
expect(events).toHaveLength(1); // No new event
555+
});
556+
557+
it('should emit events for bulk operations', async () => {
558+
const ds = createDS();
559+
const events: any[] = [];
560+
ds.onMutation((e) => events.push(e));
561+
562+
await ds.bulk!('users', 'create', [
563+
{ name: 'Bulk1' },
564+
{ name: 'Bulk2' },
565+
]);
566+
567+
// Bulk create calls create() for each item
568+
expect(events).toHaveLength(2);
569+
expect(events.every((e: any) => e.type === 'create')).toBe(true);
570+
});
571+
});

packages/plugin-calendar/src/ObjectCalendar.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,19 @@ export const ObjectCalendar: React.FC<ObjectCalendarProps> = ({
168168
const [view, setView] = useState<'month' | 'week' | 'day'>('month');
169169
const [refreshKey, setRefreshKey] = useState(0);
170170

171+
// P2: Auto-subscribe to DataSource mutation events (standalone mode only).
172+
// When rendered as a child of ObjectView with external data, parent handles refresh.
173+
useEffect(() => {
174+
if (hasExternalData) return; // Parent handles refresh
175+
if (!dataSource?.onMutation || !schema.objectName) return;
176+
const unsub = dataSource.onMutation((event: any) => {
177+
if (event.resource === schema.objectName) {
178+
setRefreshKey(k => k + 1);
179+
}
180+
});
181+
return unsub;
182+
}, [dataSource, schema.objectName, hasExternalData]);
183+
171184
const handlePullRefresh = useCallback(async () => {
172185
setRefreshKey(k => k + 1);
173186
}, []);

packages/plugin-kanban/src/ObjectKanban.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,24 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
4646
// loading state
4747
const [loading, setLoading] = useState(hasExternalData ? (externalLoading ?? false) : false);
4848
const [error, setError] = useState<Error | null>(null);
49+
const [refreshKey, setRefreshKey] = useState(0);
4950

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

54+
// P2: Auto-subscribe to DataSource mutation events (standalone mode only).
55+
// When rendered as a child of ListView, data is managed externally and this is skipped.
56+
useEffect(() => {
57+
if (hasExternalData) return; // Parent handles refresh
58+
if (!dataSource?.onMutation || !schema.objectName) return;
59+
const unsub = dataSource.onMutation((event: any) => {
60+
if (event.resource === schema.objectName) {
61+
setRefreshKey(k => k + 1);
62+
}
63+
});
64+
return unsub;
65+
}, [dataSource, schema.objectName, hasExternalData]);
66+
5367
// Sync external data changes from parent (e.g. ListView re-fetches after filter change)
5468
useEffect(() => {
5569
if (hasExternalData && externalLoading !== undefined) {
@@ -109,7 +123,7 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
109123
fetchData();
110124
}
111125
return () => { isMounted = false; };
112-
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, hasExternalData, objectDef]);
126+
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, hasExternalData, objectDef, refreshKey]);
113127

114128
// Determine which data to use: external -> bound -> inline -> fetched
115129
const rawData = (hasExternalData ? externalData : undefined) || boundData || schema.data || fetchedData;

packages/plugin-list/src/ListView.tsx

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,24 @@ function useListFieldLabel() {
273273
}
274274
}
275275

276-
export const ListView: React.FC<ListViewProps> = ({
276+
/**
277+
* Imperative handle exposed by ListView via React.forwardRef.
278+
* Allows parent components to trigger a data refresh programmatically.
279+
*
280+
* @example
281+
* ```tsx
282+
* const listRef = React.useRef<ListViewHandle>(null);
283+
* <ListView ref={listRef} schema={schema} />
284+
* // After a mutation:
285+
* listRef.current?.refresh();
286+
* ```
287+
*/
288+
export interface ListViewHandle {
289+
/** Force the ListView to re-fetch data from the DataSource */
290+
refresh(): void;
291+
}
292+
293+
export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
277294
schema: propSchema,
278295
className,
279296
onViewChange,
@@ -283,7 +300,7 @@ export const ListView: React.FC<ListViewProps> = ({
283300
onRowClick,
284301
showViewSwitcher = false,
285302
...props
286-
}) => {
303+
}, ref) => {
287304
// i18n support for record count and other labels
288305
const { t } = useListViewTranslation();
289306
const { fieldLabel: resolveFieldLabel } = useListFieldLabel();
@@ -393,6 +410,24 @@ export const ListView: React.FC<ListViewProps> = ({
393410
const [refreshKey, setRefreshKey] = React.useState(0);
394411
const [dataLimitReached, setDataLimitReached] = React.useState(false);
395412

413+
// --- P1: Imperative refresh API ---
414+
React.useImperativeHandle(ref, () => ({
415+
refresh: () => setRefreshKey(k => k + 1),
416+
}), []);
417+
418+
// --- P2: Auto-subscribe to DataSource mutation events ---
419+
// When an external refreshTrigger is provided, rely on that instead of
420+
// subscribing to dataSource mutations to avoid double refreshes.
421+
React.useEffect(() => {
422+
if (!dataSource?.onMutation || !schema.objectName || schema.refreshTrigger) return;
423+
const unsub = dataSource.onMutation((event) => {
424+
if (event.resource === schema.objectName) {
425+
setRefreshKey(k => k + 1);
426+
}
427+
});
428+
return unsub;
429+
}, [dataSource, schema.objectName, schema.refreshTrigger]);
430+
396431
// Dynamic page size state (wired from pageSizeOptions selector)
397432
const [dynamicPageSize, setDynamicPageSize] = React.useState<number | undefined>(undefined);
398433
const effectivePageSize = dynamicPageSize ?? schema.pagination?.pageSize ?? 100;
@@ -683,7 +718,7 @@ export const ListView: React.FC<ListViewProps> = ({
683718
fetchData();
684719

685720
return () => { isMounted = false; };
686-
}, [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
721+
}, [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
687722

688723
// Available view types based on schema configuration
689724
const availableViews = React.useMemo(() => {
@@ -1685,4 +1720,6 @@ export const ListView: React.FC<ListViewProps> = ({
16851720
)}
16861721
</div>
16871722
);
1688-
};
1723+
});
1724+
1725+
ListView.displayName = 'ListView';

0 commit comments

Comments
 (0)