Skip to content

Commit d510bbd

Browse files
Copilothotlong
andcommitted
feat: complete P1/P2 — add useDataRefresh hook, onMutation for ObjectView/Kanban/Calendar
- 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
1 parent a8ba029 commit d510bbd

File tree

7 files changed

+219
-4
lines changed

7 files changed

+219
-4
lines changed

CHANGELOG.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ 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/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:
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:
1717
- **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**: `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`.
19-
- **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.
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.
2020

2121
- **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.
2222

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-view/src/ObjectView.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,20 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
224224
const [selectedRecord, setSelectedRecord] = useState<Record<string, unknown> | null>(null);
225225
const [refreshKey, setRefreshKey] = useState(0);
226226

227+
// P2: Auto-subscribe to DataSource mutation events for non-grid views.
228+
// When a DataSource implements onMutation(), ObjectView auto-refreshes
229+
// its own data fetch (for non-grid view types like kanban, calendar, etc.)
230+
// whenever a create/update/delete occurs on the same objectName.
231+
useEffect(() => {
232+
if (!dataSource?.onMutation || !schema.objectName) return;
233+
const unsub = dataSource.onMutation((event: any) => {
234+
if (event.resource === schema.objectName) {
235+
setRefreshKey(prev => prev + 1);
236+
}
237+
});
238+
return unsub;
239+
}, [dataSource, schema.objectName]);
240+
227241
// Data fetching state for non-grid views
228242
const [data, setData] = useState<any[]>([]);
229243
const [loading, setLoading] = useState(false);
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* ObjectUI — useDataRefresh Tests
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* Tests for the reusable data-refresh hook (P1/P2).
6+
*/
7+
8+
import { describe, it, expect, vi } from 'vitest';
9+
import { renderHook, act } from '@testing-library/react';
10+
import { useDataRefresh } from '../useDataRefresh';
11+
12+
describe('useDataRefresh', () => {
13+
it('should return refreshKey=0 and a refresh function', () => {
14+
const { result } = renderHook(() => useDataRefresh(undefined, undefined));
15+
16+
expect(result.current.refreshKey).toBe(0);
17+
expect(typeof result.current.refresh).toBe('function');
18+
});
19+
20+
it('should increment refreshKey when refresh() is called', () => {
21+
const { result } = renderHook(() => useDataRefresh(undefined, 'contacts'));
22+
23+
act(() => {
24+
result.current.refresh();
25+
});
26+
27+
expect(result.current.refreshKey).toBe(1);
28+
29+
act(() => {
30+
result.current.refresh();
31+
});
32+
33+
expect(result.current.refreshKey).toBe(2);
34+
});
35+
36+
it('should auto-subscribe to DataSource.onMutation() when available', () => {
37+
let listener: ((event: any) => void) | null = null;
38+
const unsub = vi.fn();
39+
const ds: any = {
40+
onMutation: vi.fn((cb: any) => {
41+
listener = cb;
42+
return unsub;
43+
}),
44+
};
45+
46+
const { result } = renderHook(() => useDataRefresh(ds, 'contacts'));
47+
48+
expect(ds.onMutation).toHaveBeenCalledOnce();
49+
50+
// Simulate a mutation on the same resource
51+
act(() => {
52+
listener?.({ type: 'create', resource: 'contacts' });
53+
});
54+
55+
expect(result.current.refreshKey).toBe(1);
56+
});
57+
58+
it('should NOT increment refreshKey for mutations on a different resource', () => {
59+
let listener: ((event: any) => void) | null = null;
60+
const ds: any = {
61+
onMutation: vi.fn((cb: any) => {
62+
listener = cb;
63+
return vi.fn();
64+
}),
65+
};
66+
67+
const { result } = renderHook(() => useDataRefresh(ds, 'contacts'));
68+
69+
act(() => {
70+
listener?.({ type: 'create', resource: 'accounts' });
71+
});
72+
73+
expect(result.current.refreshKey).toBe(0);
74+
});
75+
76+
it('should unsubscribe on unmount', () => {
77+
const unsub = vi.fn();
78+
const ds: any = {
79+
onMutation: vi.fn(() => unsub),
80+
};
81+
82+
const { unmount } = renderHook(() => useDataRefresh(ds, 'contacts'));
83+
84+
expect(unsub).not.toHaveBeenCalled();
85+
86+
unmount();
87+
88+
expect(unsub).toHaveBeenCalledOnce();
89+
});
90+
91+
it('should work without onMutation (backward compatible)', () => {
92+
const ds: any = {
93+
find: vi.fn(),
94+
};
95+
96+
const { result } = renderHook(() => useDataRefresh(ds, 'contacts'));
97+
98+
expect(result.current.refreshKey).toBe(0);
99+
// Should not throw
100+
act(() => {
101+
result.current.refresh();
102+
});
103+
expect(result.current.refreshKey).toBe(1);
104+
});
105+
106+
it('should skip subscription when objectName is undefined', () => {
107+
const ds: any = {
108+
onMutation: vi.fn(() => vi.fn()),
109+
};
110+
111+
renderHook(() => useDataRefresh(ds, undefined));
112+
113+
expect(ds.onMutation).not.toHaveBeenCalled();
114+
});
115+
});

packages/react/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ export * from './useSchemaPersistence';
3333
export * from './useGlobalUndo';
3434
export * from './useDebugMode';
3535
export * from './useActionEngine';
36+
export * from './useDataRefresh';
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { useState, useCallback, useEffect } from 'react';
10+
import type { DataSource } from '@object-ui/types';
11+
12+
/**
13+
* Reusable hook that encapsulates the data-refresh pattern:
14+
* 1. A numeric `refreshKey` that triggers React effect re-runs when incremented.
15+
* 2. An imperative `refresh()` function to trigger a re-fetch on demand.
16+
* 3. Auto-subscription to `DataSource.onMutation()` — if the DataSource
17+
* implements the optional mutation event bus, the hook auto-refreshes
18+
* whenever a mutation occurs on the matching `objectName`.
19+
*
20+
* @param dataSource - The DataSource instance (may be undefined during initial render)
21+
* @param objectName - The resource/object name to watch for mutations
22+
* @returns `{ refreshKey, refresh }` — include `refreshKey` in your effect deps
23+
*
24+
* @example
25+
* ```tsx
26+
* const { refreshKey, refresh } = useDataRefresh(dataSource, schema.objectName);
27+
*
28+
* useEffect(() => {
29+
* dataSource.find(objectName, params).then(setData);
30+
* }, [objectName, refreshKey]);
31+
*
32+
* // Or trigger manually:
33+
* <button onClick={refresh}>Refresh</button>
34+
* ```
35+
*/
36+
export function useDataRefresh(
37+
dataSource: DataSource | undefined,
38+
objectName: string | undefined,
39+
): { refreshKey: number; refresh: () => void } {
40+
const [refreshKey, setRefreshKey] = useState(0);
41+
42+
const refresh = useCallback(() => {
43+
setRefreshKey(k => k + 1);
44+
}, []);
45+
46+
// Auto-subscribe to DataSource mutation events
47+
useEffect(() => {
48+
if (!dataSource?.onMutation || !objectName) return;
49+
const unsub = dataSource.onMutation((event) => {
50+
if (event.resource === objectName) {
51+
setRefreshKey(k => k + 1);
52+
}
53+
});
54+
return unsub;
55+
}, [dataSource, objectName]);
56+
57+
return { refreshKey, refresh };
58+
}

0 commit comments

Comments
 (0)