Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
45 changes: 41 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,24 @@ 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 ---
// 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 || schema.refreshTrigger) return;
const unsub = dataSource.onMutation((event) => {
if (event.resource === schema.objectName) {
setRefreshKey(k => k + 1);
}
});
return unsub;
}, [dataSource, schema.objectName, schema.refreshTrigger]);

// 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 +718,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 +1720,6 @@ export const ListView: React.FC<ListViewProps> = ({
)}
</div>
);
};
});

ListView.displayName = 'ListView';
Loading