Skip to content

Commit f591c5d

Browse files
Copilothotlong
andcommitted
feat: standardize list refresh after mutation (P0/P1/P2)
P0: Add refreshTrigger prop to ListViewSchema, wire it through ObjectView renderListView → ListView data fetch effect. P1: ListView now exposes imperative refresh() via forwardRef. P2: Add MutationEvent type + onMutation() subscriber to DataSource, implement in ValueDataSource, auto-subscribe in ListView. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/3c0631ac-e239-4237-bce5-9d8dad44cf99
1 parent d00ea6a commit f591c5d

File tree

11 files changed

+494
-9
lines changed

11 files changed

+494
-9
lines changed

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 { /* swallow listener errors */ }
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-list/src/ListView.tsx

Lines changed: 39 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,22 @@ 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+
React.useEffect(() => {
420+
if (!dataSource?.onMutation || !schema.objectName) return;
421+
const unsub = dataSource.onMutation((event) => {
422+
if (event.resource === schema.objectName) {
423+
setRefreshKey(k => k + 1);
424+
}
425+
});
426+
return unsub;
427+
}, [dataSource, schema.objectName]);
428+
396429
// Dynamic page size state (wired from pageSizeOptions selector)
397430
const [dynamicPageSize, setDynamicPageSize] = React.useState<number | undefined>(undefined);
398431
const effectivePageSize = dynamicPageSize ?? schema.pagination?.pageSize ?? 100;
@@ -683,7 +716,7 @@ export const ListView: React.FC<ListViewProps> = ({
683716
fetchData();
684717

685718
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
719+
}, [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
687720

688721
// Available view types based on schema configuration
689722
const availableViews = React.useMemo(() => {
@@ -1685,4 +1718,6 @@ export const ListView: React.FC<ListViewProps> = ({
16851718
)}
16861719
</div>
16871720
);
1688-
};
1721+
});
1722+
1723+
ListView.displayName = 'ListView';

0 commit comments

Comments
 (0)