From d00ea6a07a66b968248a3fe37fb23a9c83ee3c42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 06:23:54 +0000 Subject: [PATCH 1/5] Initial plan From f591c5df325d929198483d59b07a5f9d8ccda408 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 06:42:10 +0000 Subject: [PATCH 2/5] feat: standardize list refresh after mutation (P0/P1/P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/console/src/components/ObjectView.tsx | 6 +- packages/core/src/adapters/ValueDataSource.ts | 21 ++ .../__tests__/ValueDataSource.test.ts | 99 +++++++ packages/plugin-list/src/ListView.tsx | 43 ++- .../src/__tests__/ListRefresh.test.tsx | 276 ++++++++++++++++++ .../src/__tests__/ListView.test.tsx | 6 +- packages/plugin-list/src/index.tsx | 2 +- packages/plugin-view/src/ObjectView.tsx | 5 + packages/types/src/data.ts | 36 +++ packages/types/src/index.ts | 1 + packages/types/src/objectql.ts | 8 + 11 files changed, 494 insertions(+), 9 deletions(-) create mode 100644 packages/plugin-list/src/__tests__/ListRefresh.test.tsx diff --git a/apps/console/src/components/ObjectView.tsx b/apps/console/src/components/ObjectView.tsx index 3c61240d9..dfdaaf492 100644 --- a/apps/console/src/components/ObjectView.tsx +++ b/apps/console/src/components/ObjectView.tsx @@ -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 diff --git a/packages/core/src/adapters/ValueDataSource.ts b/packages/core/src/adapters/ValueDataSource.ts index 60671b2da..eea732a7e 100644 --- a/packages/core/src/adapters/ValueDataSource.ts +++ b/packages/core/src/adapters/ValueDataSource.ts @@ -11,6 +11,7 @@ import type { DataSource, + MutationEvent, QueryParams, QueryResult, AggregateParams, @@ -228,6 +229,7 @@ function selectFields(record: T, fields?: string[]): T { export class ValueDataSource implements DataSource { private items: T[]; private idField: string | undefined; + private mutationListeners = new Set<(event: MutationEvent) => void>(); constructor(config: ValueDataSourceConfig) { // Deep clone to prevent external mutation @@ -235,6 +237,13 @@ export class ValueDataSource implements DataSource { this.idField = config.idField; } + /** Notify all mutation subscribers */ + private emitMutation(event: MutationEvent): void { + for (const listener of this.mutationListeners) { + try { listener(event); } catch { /* swallow listener errors */ } + } + } + // ----------------------------------------------------------------------- // DataSource interface // ----------------------------------------------------------------------- @@ -308,6 +317,7 @@ export class ValueDataSource implements DataSource { (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 }; } @@ -323,6 +333,7 @@ export class ValueDataSource implements DataSource { 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] }; } @@ -332,6 +343,7 @@ export class ValueDataSource implements DataSource { ); if (index === -1) return false; this.items.splice(index, 1); + this.emitMutation({ type: 'delete', resource: _resource, id }); return true; } @@ -422,6 +434,15 @@ export class ValueDataSource implements DataSource { }); } + // ----------------------------------------------------------------------- + // Mutation subscription (P2 — Event Bus) + // ----------------------------------------------------------------------- + + onMutation(callback: (event: MutationEvent) => void): () => void { + this.mutationListeners.add(callback); + return () => { this.mutationListeners.delete(callback); }; + } + // ----------------------------------------------------------------------- // Extra utilities // ----------------------------------------------------------------------- diff --git a/packages/core/src/adapters/__tests__/ValueDataSource.test.ts b/packages/core/src/adapters/__tests__/ValueDataSource.test.ts index 3efff311e..d03fa188a 100644 --- a/packages/core/src/adapters/__tests__/ValueDataSource.test.ts +++ b/packages/core/src/adapters/__tests__/ValueDataSource.test.ts @@ -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); + }); +}); diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index e554c7b95..44ac476cd 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -273,7 +273,24 @@ function useListFieldLabel() { } } -export const ListView: React.FC = ({ +/** + * Imperative handle exposed by ListView via React.forwardRef. + * Allows parent components to trigger a data refresh programmatically. + * + * @example + * ```tsx + * const listRef = React.useRef(null); + * + * // 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(({ schema: propSchema, className, onViewChange, @@ -283,7 +300,7 @@ export const ListView: React.FC = ({ onRowClick, showViewSwitcher = false, ...props -}) => { +}, ref) => { // i18n support for record count and other labels const { t } = useListViewTranslation(); const { fieldLabel: resolveFieldLabel } = useListFieldLabel(); @@ -393,6 +410,22 @@ export const ListView: React.FC = ({ 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]); + // Dynamic page size state (wired from pageSizeOptions selector) const [dynamicPageSize, setDynamicPageSize] = React.useState(undefined); const effectivePageSize = dynamicPageSize ?? schema.pagination?.pageSize ?? 100; @@ -683,7 +716,7 @@ export const ListView: React.FC = ({ 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(() => { @@ -1685,4 +1718,6 @@ export const ListView: React.FC = ({ )} ); -}; +}); + +ListView.displayName = 'ListView'; \ No newline at end of file diff --git a/packages/plugin-list/src/__tests__/ListRefresh.test.tsx b/packages/plugin-list/src/__tests__/ListRefresh.test.tsx new file mode 100644 index 000000000..180713aa9 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListRefresh.test.tsx @@ -0,0 +1,276 @@ +/** + * ObjectUI — List Refresh Tests + * Tests for the standardized list refresh after mutation mechanism. + * + * Covers: + * - P0: refreshTrigger prop triggers data re-fetch + * - P1: Imperative refresh() via forwardRef + * - P2: Auto-refresh via DataSource.onMutation() + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as React from 'react'; +import { render, act } from '@testing-library/react'; +import { ListView } from '../ListView'; +import type { ListViewHandle } from '../ListView'; +import type { ListViewSchema } from '@object-ui/types'; +import { SchemaRendererProvider } from '@object-ui/react'; + +let mockDataSource: any; + +const renderWithProvider = (component: React.ReactNode, ds?: any) => + render( + + {component} + , + ); + +describe('ListView Refresh Mechanisms', () => { + beforeEach(() => { + mockDataSource = { + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'contacts', fields: {} }), + }; + }); + + // ========================================================================= + // P0: refreshTrigger schema prop + // ========================================================================= + describe('P0 — refreshTrigger prop', () => { + it('should re-fetch data when refreshTrigger changes', async () => { + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + fields: ['name'], + refreshTrigger: 0, + }; + + const { rerender } = renderWithProvider( + , + ); + + // Wait for initial data fetch + await vi.waitFor(() => { + expect(mockDataSource.find).toHaveBeenCalled(); + }); + + const initialCallCount = mockDataSource.find.mock.calls.length; + + // Increment refreshTrigger → should trigger a new data fetch + const updatedSchema: ListViewSchema = { ...schema, refreshTrigger: 1 }; + rerender( + + + , + ); + + await vi.waitFor(() => { + expect(mockDataSource.find.mock.calls.length).toBeGreaterThan(initialCallCount); + }); + }); + + it('should NOT re-fetch when refreshTrigger stays the same', async () => { + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + fields: ['name'], + refreshTrigger: 5, + }; + + const { rerender } = renderWithProvider( + , + ); + + await vi.waitFor(() => { + expect(mockDataSource.find).toHaveBeenCalled(); + }); + + const callCount = mockDataSource.find.mock.calls.length; + + // Re-render with the same refreshTrigger → no extra fetch + rerender( + + + , + ); + + // Wait a tick and verify no extra call + await new Promise(r => setTimeout(r, 100)); + expect(mockDataSource.find.mock.calls.length).toBe(callCount); + }); + }); + + // ========================================================================= + // P1: Imperative refresh() via forwardRef + // ========================================================================= + describe('P1 — imperative refresh() API', () => { + it('should expose a refresh() method via ref', () => { + const ref = React.createRef(); + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + fields: ['name'], + }; + + renderWithProvider(); + + expect(ref.current).toBeDefined(); + expect(typeof ref.current?.refresh).toBe('function'); + }); + + it('calling refresh() should trigger a data re-fetch', async () => { + const ref = React.createRef(); + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + fields: ['name'], + }; + + renderWithProvider(); + + await vi.waitFor(() => { + expect(mockDataSource.find).toHaveBeenCalled(); + }); + + const callCount = mockDataSource.find.mock.calls.length; + + // Call imperative refresh + act(() => { + ref.current?.refresh(); + }); + + await vi.waitFor(() => { + expect(mockDataSource.find.mock.calls.length).toBeGreaterThan(callCount); + }); + }); + }); + + // ========================================================================= + // P2: Auto-refresh via DataSource.onMutation() + // ========================================================================= + describe('P2 — DataSource.onMutation() auto-refresh', () => { + it('should auto-refresh when a mutation event fires for the same resource', async () => { + let mutationCallback: ((event: any) => void) | null = null; + const unsub = vi.fn(); + + const dsWithMutation = { + ...mockDataSource, + onMutation: vi.fn((cb: any) => { + mutationCallback = cb; + return unsub; + }), + }; + + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + fields: ['name'], + }; + + renderWithProvider( + , + dsWithMutation, + ); + + await vi.waitFor(() => { + expect(dsWithMutation.find).toHaveBeenCalled(); + }); + + const callCount = dsWithMutation.find.mock.calls.length; + + // Simulate a mutation on the same resource + act(() => { + mutationCallback?.({ type: 'create', resource: 'contacts', record: { id: '1' } }); + }); + + await vi.waitFor(() => { + expect(dsWithMutation.find.mock.calls.length).toBeGreaterThan(callCount); + }); + }); + + it('should NOT refresh when a mutation fires for a different resource', async () => { + let mutationCallback: ((event: any) => void) | null = null; + + const dsWithMutation = { + ...mockDataSource, + onMutation: vi.fn((cb: any) => { + mutationCallback = cb; + return vi.fn(); + }), + }; + + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + fields: ['name'], + }; + + renderWithProvider( + , + dsWithMutation, + ); + + await vi.waitFor(() => { + expect(dsWithMutation.find).toHaveBeenCalled(); + }); + + const callCount = dsWithMutation.find.mock.calls.length; + + // Fire a mutation for a different resource + act(() => { + mutationCallback?.({ type: 'create', resource: 'accounts', record: { id: '2' } }); + }); + + // Should NOT trigger a refresh + await new Promise(r => setTimeout(r, 100)); + expect(dsWithMutation.find.mock.calls.length).toBe(callCount); + }); + + it('should unsubscribe when unmounted', async () => { + const unsub = vi.fn(); + const dsWithMutation = { + ...mockDataSource, + onMutation: vi.fn(() => unsub), + }; + + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + fields: ['name'], + }; + + const { unmount } = renderWithProvider( + , + dsWithMutation, + ); + + await vi.waitFor(() => { + expect(dsWithMutation.onMutation).toHaveBeenCalled(); + }); + + unmount(); + expect(unsub).toHaveBeenCalled(); + }); + + it('should work without onMutation (backward compatible)', async () => { + // DataSource without onMutation — should not throw + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + fields: ['name'], + }; + + renderWithProvider( + , + ); + + await vi.waitFor(() => { + expect(mockDataSource.find).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/plugin-list/src/__tests__/ListView.test.tsx b/packages/plugin-list/src/__tests__/ListView.test.tsx index 933f1aa03..47669e002 100644 --- a/packages/plugin-list/src/__tests__/ListView.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.test.tsx @@ -50,8 +50,10 @@ describe('ListView', () => { expect(ListView).toBeDefined(); }); - it('should be a function', () => { - expect(typeof ListView).toBe('function'); + it('should be a forwardRef component', () => { + // React.forwardRef wraps the component — typeof is 'object' with a render function + expect(typeof ListView).toBe('object'); + expect(typeof (ListView as any).render).toBe('function'); }); it('should render with basic schema', () => { diff --git a/packages/plugin-list/src/index.tsx b/packages/plugin-list/src/index.tsx index 3cc4e339a..c523e152e 100644 --- a/packages/plugin-list/src/index.tsx +++ b/packages/plugin-list/src/index.tsx @@ -17,7 +17,7 @@ export type { TabBarProps, ViewTab } from './components/TabBar'; export { UserFilters } from './UserFilters'; export type { UserFiltersProps } from './UserFilters'; export { evaluateConditionalFormatting, normalizeFilterCondition, normalizeFilters } from './ListView'; -export type { ListViewProps } from './ListView'; +export type { ListViewProps, ListViewHandle } from './ListView'; export type { ObjectGalleryProps } from './ObjectGallery'; export type { ViewSwitcherProps, ViewType } from './ViewSwitcher'; diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 5b228f07a..872fa5b91 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -128,6 +128,8 @@ export interface ObjectViewProps { onEdit?: (record: Record) => void; onRowClick?: (record: Record) => void; className?: string; + /** Current refresh counter — increment signals that a mutation occurred */ + refreshKey?: number; }) => React.ReactNode; /** @@ -897,11 +899,14 @@ export const ObjectView: React.FC = ({ emptyState: activeView?.emptyState ?? (schema as any).emptyState, aria: activeView?.aria ?? (schema as any).aria, tabs: (schema as any).tabs, + // Propagate refresh signal so ListView re-fetches after mutations + refreshTrigger: refreshKey, }, dataSource, onEdit: handleEdit, onRowClick: handleRowClick, className: 'h-full', + refreshKey, }); } diff --git a/packages/types/src/data.ts b/packages/types/src/data.ts index 2a9a53f59..243aefa69 100644 --- a/packages/types/src/data.ts +++ b/packages/types/src/data.ts @@ -322,6 +322,42 @@ export interface DataSource { * @returns Promise resolving to aggregated results */ aggregate?(resource: string, params: AggregateParams): Promise; + + /** + * Subscribe to mutation events. + * When implemented, data-bound views (ListView, ObjectView) can auto-refresh + * after any create/update/delete operation on relevant resources. + * + * @param callback - Invoked after each successful mutation + * @returns Unsubscribe function to remove the listener + * + * @example + * ```typescript + * const unsub = dataSource.onMutation?.((event) => { + * if (event.resource === 'contacts') { + * refreshList(); + * } + * }); + * // later… + * unsub?.(); + * ``` + */ + onMutation?(callback: (event: MutationEvent) => void): () => void; +} + +/** + * Describes a mutation that occurred on a DataSource. + * Emitted by `DataSource.onMutation` subscribers after create/update/delete. + */ +export interface MutationEvent { + /** The type of mutation that occurred */ + type: 'create' | 'update' | 'delete'; + /** The resource (object) name that was mutated */ + resource: string; + /** The affected record (present for create/update) */ + record?: T; + /** The ID of the affected record (present for update/delete) */ + id?: string | number; } /** diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f84f189b0..de6aeae63 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -268,6 +268,7 @@ export type { FileUploadResult, AggregateParams, AggregateResult, + MutationEvent, } from './data'; // ============================================================================ diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 77ef0add4..020cc153b 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1755,6 +1755,14 @@ export interface ListViewSchema extends BaseSchema { * @default false */ allowPrinting?: boolean; + + /** + * External refresh trigger. + * Increment this value to force the ListView to re-fetch data. + * Used by parent components (e.g., ObjectView) to signal that a mutation + * (create/update/delete) has occurred and the list should refresh. + */ + refreshTrigger?: number; } export interface ObjectMapSchema extends BaseSchema { type: 'object-map'; From a8ba02939a74ac368fcf8c7ff96792bac66b0d6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 06:50:01 +0000 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20address=20code=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20log=20listener=20errors=20in=20ValueDataSource,?= =?UTF-8?q?=20update=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 5 +++++ packages/core/src/adapters/ValueDataSource.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbaea7d18..e72230397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/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**: `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`. + - **P2 — DataSource Mutation Event Bus**: Added `MutationEvent` interface and optional `onMutation(callback): unsubscribe` method to the `DataSource` interface. When a DataSource implements this, `ListView` auto-subscribes and refreshes on matching resource mutations. `ValueDataSource` now emits mutation events on create/update/delete. Includes 15 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. diff --git a/packages/core/src/adapters/ValueDataSource.ts b/packages/core/src/adapters/ValueDataSource.ts index eea732a7e..ab9fa7b92 100644 --- a/packages/core/src/adapters/ValueDataSource.ts +++ b/packages/core/src/adapters/ValueDataSource.ts @@ -240,7 +240,7 @@ export class ValueDataSource implements DataSource { /** Notify all mutation subscribers */ private emitMutation(event: MutationEvent): void { for (const listener of this.mutationListeners) { - try { listener(event); } catch { /* swallow listener errors */ } + try { listener(event); } catch (err) { console.warn('ValueDataSource: mutation listener error', err); } } } From d510bbd7ad97a5b543859203bf4382b13fbcb139 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:11:56 +0000 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20complete=20P1/P2=20=E2=80=94=20add?= =?UTF-8?q?=20useDataRefresh=20hook,=20onMutation=20for=20ObjectView/Kanba?= =?UTF-8?q?n/Calendar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: Create useDataRefresh() hook in @object-ui/react with 7 tests - P2: Add onMutation subscription to plugin-view ObjectView - P2: Add onMutation subscription to plugin-kanban ObjectKanban - P2: Add onMutation subscription to plugin-calendar ObjectCalendar - Updated CHANGELOG with complete scope Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/d269c01c-4c5b-4b90-8d2e-1427e459078f --- CHANGELOG.md | 6 +- .../plugin-calendar/src/ObjectCalendar.tsx | 13 ++ packages/plugin-kanban/src/ObjectKanban.tsx | 16 ++- packages/plugin-view/src/ObjectView.tsx | 14 +++ .../hooks/__tests__/useDataRefresh.test.ts | 115 ++++++++++++++++++ packages/react/src/hooks/index.ts | 1 + packages/react/src/hooks/useDataRefresh.ts | 58 +++++++++ 7 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 packages/react/src/hooks/__tests__/useDataRefresh.test.ts create mode 100644 packages/react/src/hooks/useDataRefresh.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e72230397..f1eb92dbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,10 @@ 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/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: +- **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**: `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`. - - **P2 — DataSource Mutation Event Bus**: Added `MutationEvent` interface and optional `onMutation(callback): unsubscribe` method to the `DataSource` interface. When a DataSource implements this, `ListView` auto-subscribes and refreshes on matching resource mutations. `ValueDataSource` now emits mutation events on create/update/delete. Includes 15 new tests covering all three phases. + - **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. diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index c973d711f..1e8f225d5 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -168,6 +168,19 @@ export const ObjectCalendar: React.FC = ({ 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); }, []); diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index 3e5590bee..a2de62fd5 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -46,10 +46,24 @@ export const ObjectKanban: React.FC = ({ // loading state const [loading, setLoading] = useState(hasExternalData ? (externalLoading ?? false) : false); const [error, setError] = useState(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) { @@ -109,7 +123,7 @@ export const ObjectKanban: React.FC = ({ 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; diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 872fa5b91..af2176004 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -224,6 +224,20 @@ export const ObjectView: React.FC = ({ const [selectedRecord, setSelectedRecord] = useState | null>(null); const [refreshKey, setRefreshKey] = useState(0); + // P2: Auto-subscribe to DataSource mutation events for non-grid views. + // When a DataSource implements onMutation(), ObjectView auto-refreshes + // its own data fetch (for non-grid view types like kanban, calendar, etc.) + // whenever a create/update/delete occurs on the same objectName. + useEffect(() => { + if (!dataSource?.onMutation || !schema.objectName) return; + const unsub = dataSource.onMutation((event: any) => { + if (event.resource === schema.objectName) { + setRefreshKey(prev => prev + 1); + } + }); + return unsub; + }, [dataSource, schema.objectName]); + // Data fetching state for non-grid views const [data, setData] = useState([]); const [loading, setLoading] = useState(false); diff --git a/packages/react/src/hooks/__tests__/useDataRefresh.test.ts b/packages/react/src/hooks/__tests__/useDataRefresh.test.ts new file mode 100644 index 000000000..4ec6a578a --- /dev/null +++ b/packages/react/src/hooks/__tests__/useDataRefresh.test.ts @@ -0,0 +1,115 @@ +/** + * ObjectUI — useDataRefresh Tests + * Copyright (c) 2024-present ObjectStack Inc. + * + * Tests for the reusable data-refresh hook (P1/P2). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useDataRefresh } from '../useDataRefresh'; + +describe('useDataRefresh', () => { + it('should return refreshKey=0 and a refresh function', () => { + const { result } = renderHook(() => useDataRefresh(undefined, undefined)); + + expect(result.current.refreshKey).toBe(0); + expect(typeof result.current.refresh).toBe('function'); + }); + + it('should increment refreshKey when refresh() is called', () => { + const { result } = renderHook(() => useDataRefresh(undefined, 'contacts')); + + act(() => { + result.current.refresh(); + }); + + expect(result.current.refreshKey).toBe(1); + + act(() => { + result.current.refresh(); + }); + + expect(result.current.refreshKey).toBe(2); + }); + + it('should auto-subscribe to DataSource.onMutation() when available', () => { + let listener: ((event: any) => void) | null = null; + const unsub = vi.fn(); + const ds: any = { + onMutation: vi.fn((cb: any) => { + listener = cb; + return unsub; + }), + }; + + const { result } = renderHook(() => useDataRefresh(ds, 'contacts')); + + expect(ds.onMutation).toHaveBeenCalledOnce(); + + // Simulate a mutation on the same resource + act(() => { + listener?.({ type: 'create', resource: 'contacts' }); + }); + + expect(result.current.refreshKey).toBe(1); + }); + + it('should NOT increment refreshKey for mutations on a different resource', () => { + let listener: ((event: any) => void) | null = null; + const ds: any = { + onMutation: vi.fn((cb: any) => { + listener = cb; + return vi.fn(); + }), + }; + + const { result } = renderHook(() => useDataRefresh(ds, 'contacts')); + + act(() => { + listener?.({ type: 'create', resource: 'accounts' }); + }); + + expect(result.current.refreshKey).toBe(0); + }); + + it('should unsubscribe on unmount', () => { + const unsub = vi.fn(); + const ds: any = { + onMutation: vi.fn(() => unsub), + }; + + const { unmount } = renderHook(() => useDataRefresh(ds, 'contacts')); + + expect(unsub).not.toHaveBeenCalled(); + + unmount(); + + expect(unsub).toHaveBeenCalledOnce(); + }); + + it('should work without onMutation (backward compatible)', () => { + const ds: any = { + find: vi.fn(), + }; + + const { result } = renderHook(() => useDataRefresh(ds, 'contacts')); + + expect(result.current.refreshKey).toBe(0); + // Should not throw + act(() => { + result.current.refresh(); + }); + expect(result.current.refreshKey).toBe(1); + }); + + it('should skip subscription when objectName is undefined', () => { + const ds: any = { + onMutation: vi.fn(() => vi.fn()), + }; + + renderHook(() => useDataRefresh(ds, undefined)); + + expect(ds.onMutation).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 9e6575ef7..3ce0275d4 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -33,3 +33,4 @@ export * from './useSchemaPersistence'; export * from './useGlobalUndo'; export * from './useDebugMode'; export * from './useActionEngine'; +export * from './useDataRefresh'; diff --git a/packages/react/src/hooks/useDataRefresh.ts b/packages/react/src/hooks/useDataRefresh.ts new file mode 100644 index 000000000..41c250390 --- /dev/null +++ b/packages/react/src/hooks/useDataRefresh.ts @@ -0,0 +1,58 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useState, useCallback, useEffect } from 'react'; +import type { DataSource } from '@object-ui/types'; + +/** + * Reusable hook that encapsulates the data-refresh pattern: + * 1. A numeric `refreshKey` that triggers React effect re-runs when incremented. + * 2. An imperative `refresh()` function to trigger a re-fetch on demand. + * 3. Auto-subscription to `DataSource.onMutation()` — if the DataSource + * implements the optional mutation event bus, the hook auto-refreshes + * whenever a mutation occurs on the matching `objectName`. + * + * @param dataSource - The DataSource instance (may be undefined during initial render) + * @param objectName - The resource/object name to watch for mutations + * @returns `{ refreshKey, refresh }` — include `refreshKey` in your effect deps + * + * @example + * ```tsx + * const { refreshKey, refresh } = useDataRefresh(dataSource, schema.objectName); + * + * useEffect(() => { + * dataSource.find(objectName, params).then(setData); + * }, [objectName, refreshKey]); + * + * // Or trigger manually: + * + * ``` + */ +export function useDataRefresh( + dataSource: DataSource | undefined, + objectName: string | undefined, +): { refreshKey: number; refresh: () => void } { + const [refreshKey, setRefreshKey] = useState(0); + + const refresh = useCallback(() => { + setRefreshKey(k => k + 1); + }, []); + + // Auto-subscribe to DataSource mutation events + useEffect(() => { + if (!dataSource?.onMutation || !objectName) return; + const unsub = dataSource.onMutation((event) => { + if (event.resource === objectName) { + setRefreshKey(k => k + 1); + } + }); + return unsub; + }, [dataSource, objectName]); + + return { refreshKey, refresh }; +} From cd2b176aaa93d9dd0fe8a17731104b38493df4ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:59:22 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20address=20reviewer=20feedback=20?= =?UTF-8?q?=E2=80=94=20dedupe=20double=20refresh,=20use=20generic=20Mutati?= =?UTF-8?q?onEvent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. types/data.ts: onMutation now uses MutationEvent to preserve record typing 2. ObjectView: skip onMutation subscription when renderListView is provided (avoids double refresh) 3. ListView: skip onMutation subscription when refreshTrigger is provided (avoids double refresh) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/b095f623-c45f-4bc7-8ffd-a5cac05aac07 --- packages/plugin-list/src/ListView.tsx | 6 ++++-- packages/plugin-view/src/ObjectView.tsx | 7 ++++++- packages/types/src/data.ts | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 44ac476cd..91fa2517a 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -416,15 +416,17 @@ export const ListView = React.forwardRef(({ }), []); // --- P2: Auto-subscribe to DataSource mutation events --- + // When an external refreshTrigger is provided, rely on that instead of + // subscribing to dataSource mutations to avoid double refreshes. React.useEffect(() => { - if (!dataSource?.onMutation || !schema.objectName) return; + 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]); + }, [dataSource, schema.objectName, schema.refreshTrigger]); // Dynamic page size state (wired from pageSizeOptions selector) const [dynamicPageSize, setDynamicPageSize] = React.useState(undefined); diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index af2176004..cc27ae837 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -228,15 +228,20 @@ export const ObjectView: React.FC = ({ // When a DataSource implements onMutation(), ObjectView auto-refreshes // its own data fetch (for non-grid view types like kanban, calendar, etc.) // whenever a create/update/delete occurs on the same objectName. + // + // ListView-driven configurations already manage refreshKey via + // form success / delete handlers. To avoid double refreshes and + // duplicate find() calls, skip auto-subscription when renderListView is provided. useEffect(() => { if (!dataSource?.onMutation || !schema.objectName) return; + if (renderListView) return; const unsub = dataSource.onMutation((event: any) => { if (event.resource === schema.objectName) { setRefreshKey(prev => prev + 1); } }); return unsub; - }, [dataSource, schema.objectName]); + }, [dataSource, schema.objectName, renderListView]); // Data fetching state for non-grid views const [data, setData] = useState([]); diff --git a/packages/types/src/data.ts b/packages/types/src/data.ts index 243aefa69..7248ebf7d 100644 --- a/packages/types/src/data.ts +++ b/packages/types/src/data.ts @@ -342,7 +342,7 @@ export interface DataSource { * unsub?.(); * ``` */ - onMutation?(callback: (event: MutationEvent) => void): () => void; + onMutation?(callback: (event: MutationEvent) => void): () => void; } /**