From 8cff88597950d45091db4a50b2b63496914d80e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 07:44:00 +0000 Subject: [PATCH 1/2] fix: retire the view-level `provider: 'api'` readers (objectui#12325) @objectstack/spec retires the view-level `api` data source (objectstack#23082, ADR-0049). Delete every objectui reader of a ViewData `api` block: resolveDataSource's arm, ViewDataProvider's arm, useViewData's keying, ObjectGantt's adapter inputs / drawer / refresh branches, ListView's gantt stand-down, and the calendar and map "not yet implemented" branches. ApiDataSource stays a class for code-built use (the approvals inbox). Flipped pins assert the new meaning. Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- .changeset/12325-retire-api-view-data.md | 22 ++ .../services/approvalRequestsDataSource.ts | 5 +- content/docs/plugins/plugin-gantt.mdx | 37 +--- content/docs/plugins/plugin-map.mdx | 10 +- packages/core/src/adapters/ApiDataSource.ts | 14 +- packages/core/src/adapters/README.md | 15 +- .../__tests__/resolveDataSource.test.ts | 44 ++-- .../core/src/adapters/resolveDataSource.ts | 26 +-- .../core/src/data-scope/ViewDataProvider.ts | 79 +------ .../__tests__/ViewDataProvider.test.ts | 91 ++------ .../plugin-calendar/src/ObjectCalendar.tsx | 12 +- packages/plugin-gantt/README.md | 14 +- .../src/ObjectGantt.apifetch.test.tsx | 102 --------- ...ntt.discardedReloadIdentity-10036.test.tsx | 196 ++++++++---------- .../src/ObjectGantt.drawerfetch.test.tsx | 9 +- .../ObjectGantt.emptyHostData-7333.test.tsx | 104 ++++------ ...ectGantt.retiredApiProvider-12325.test.tsx | 142 +++++++++++++ .../plugin-gantt/src/ObjectGantt.test.tsx | 66 +----- packages/plugin-gantt/src/ObjectGantt.tsx | 98 ++++----- .../recordSourceInputs-10394.test.tsx | 21 +- packages/plugin-list/src/ListView.tsx | 89 ++------ .../src/__tests__/ListView.test.tsx | 60 +++--- packages/plugin-map/src/ObjectMap.tsx | 11 +- .../index.recordSourceInputs-10394.test.tsx | 8 +- .../react/src/__tests__/useViewData.test.tsx | 101 ++++----- packages/react/src/hooks/useViewData.ts | 19 +- packages/types/src/objectql.ts | 11 +- 27 files changed, 573 insertions(+), 833 deletions(-) create mode 100644 .changeset/12325-retire-api-view-data.md delete mode 100644 packages/plugin-gantt/src/ObjectGantt.apifetch.test.tsx create mode 100644 packages/plugin-gantt/src/ObjectGantt.retiredApiProvider-12325.test.tsx diff --git a/.changeset/12325-retire-api-view-data.md b/.changeset/12325-retire-api-view-data.md new file mode 100644 index 0000000000..19251969f1 --- /dev/null +++ b/.changeset/12325-retire-api-view-data.md @@ -0,0 +1,22 @@ +--- +'@object-ui/core': minor +'@object-ui/react': minor +'@object-ui/plugin-gantt': minor +'@object-ui/plugin-list': minor +'@object-ui/plugin-calendar': patch +'@object-ui/plugin-map': patch +'@object-ui/types': patch +'@object-ui/console': patch +--- + +The view-level `provider: 'api'` data source is no longer read anywhere in objectui (objectui#12325). `@objectstack/spec` retires it (objectstack#23082, ADR-0049): a view binds its object (`provider: 'object'`) or static rows (`provider: 'value'`), and a metadata-declared API call is the page element's `dataSource` binding. + +Breaking, declared as `minor` per the version-alignment rule: + +- **`resolveDataSource`** (`@object-ui/core`) has no `api` arm. A block that still says `provider: 'api'` is an unknown provider and gets an unknown provider's answer (the fallback DataSource, else `null`); no `ApiDataSource` is built from its `read` / `write`. `ResolveDataSourceOptions` loses `fetch` and `defaultHeaders`, which only that arm read. +- **`ViewDataProvider`** (`@object-ui/core`) has no `api` arm: such a config resolves with `Unknown data provider: api`. `ViewDataConfig` and `ResolvedData.provider` drop `'api'`, and `DataFetcher.fetchUrl`, which only that arm called, is removed. +- **`useViewData`** (`@object-ui/react`) no longer keys on or threads anything for an `api` block. +- **`ObjectGantt`** reads no `api` block: its `read` URL is never requested, and the chart answers it like any unknown provider. `ObjectCalendar` and `ObjectMap` drop their `api` branches, which only warned "not yet implemented". +- **`ListView`** no longer stands down for a gantt view with an `api` block: it fetches the bound object like any other list. + +Unchanged: `ApiDataSource` stays a class for code that constructs it from an `ApiDataSourceConfig`, so the console's approvals inbox reads exactly as before. diff --git a/apps/console/src/services/approvalRequestsDataSource.ts b/apps/console/src/services/approvalRequestsDataSource.ts index a6cf35323a..c2a8380e69 100644 --- a/apps/console/src/services/approvalRequestsDataSource.ts +++ b/apps/console/src/services/approvalRequestsDataSource.ts @@ -6,8 +6,9 @@ * `ListView` for a list, `RecordDetailView` for one request. Both take ONE * `dataSource`. `RecordDetailView` also sends its side reads through it * (`sys_user`, comments, activities, history). So this source routes. Reads of - * `sys_approval_request` go to the approvals routes through the existing - * `provider: 'api'` door (`ApiDataSource`). Every other resource, and every + * `sys_approval_request` go to the approvals routes through an `ApiDataSource` + * built here in code (not a view's `provider: 'api'` block, which is retired — + * objectui#12325). Every other resource, and every * other method, goes to the host adapter unchanged. The rows come back exactly * as the approvals service serves them, so `viewer` (list and get) and * `decision_progress` (get only, by contract) are ordinary fields a view binds. diff --git a/content/docs/plugins/plugin-gantt.mdx b/content/docs/plugins/plugin-gantt.mdx index fd885e97b2..e73440b640 100644 --- a/content/docs/plugins/plugin-gantt.mdx +++ b/content/docs/plugins/plugin-gantt.mdx @@ -21,7 +21,7 @@ The `@object-ui/plugin-gantt` plugin provides Gantt chart visualization for Obje ## Features -- **ObjectQL Integration**: Works seamlessly with object/api/value data providers +- **ObjectQL Integration**: Works seamlessly with object/value data providers - **Automatic Field Mapping**: Maps database fields to Gantt tasks - **Task Timeline**: Visual bars showing task duration - **Progress Tracking**: Display task completion percentage (0-100%) @@ -332,35 +332,14 @@ const valueProviderGantt: ObjectGanttBlockNode = { }; ``` -### API Provider +### No API provider -```tsx -import type { ObjectGanttBlockNode } from '@object-ui/types'; - -const apiProviderGantt: ObjectGanttBlockNode = { - type: 'object-gantt', - properties: { - data: { - provider: 'api', - // the api member is `read` / `write` HTTP requests — there is no - // top-level `endpoint` key, and nothing reads one - read: { url: '/api/project/tasks', method: 'GET' } - }, - gantt: { - startDateField: 'startDate', - endDateField: 'endDate', - titleField: 'taskName', - progressField: 'percentComplete' - } - } -}; -``` - -When the view renders inside a `SchemaRendererProvider` that supplies an -`apiFetch` (the console host wires an authenticated fetch there), api-provider -requests carry the same credentials — `Authorization`, tenant, and locale -headers — as native platform requests. Without it, requests fall back to the -bare global fetch and rely on same-origin cookies alone. +The view-level `{ provider: 'api', read, write }` data source is retired in +`@objectstack/spec` (objectstack#23082). A gantt reads its object +(`provider: 'object'`) or inline rows (`provider: 'value'`); a +metadata-declared API call is the page element's `dataSource` binding. A +gantt that still carries an `api` block is not read through it: its `read` / +`write` URLs are never requested. ## Event Handling diff --git a/content/docs/plugins/plugin-map.mdx b/content/docs/plugins/plugin-map.mdx index be0bf6d72b..0de684c13f 100644 --- a/content/docs/plugins/plugin-map.mdx +++ b/content/docs/plugins/plugin-map.mdx @@ -314,13 +314,13 @@ const staticLocations = { }; ``` -### API Provider — not implemented +### No API provider -`data.provider: 'api'` has no fetch implementation in `ObjectMap`. A schema that -reaches this branch logs `API provider not yet implemented for ObjectMap`, sets -the record set to empty and renders a map with no markers; `endpoint` and -`method` have no read point anywhere in the package. Without a `DataSource` it +The view-level `data.provider: 'api'` is retired in `@objectstack/spec` +(objectstack#23082), and `ObjectMap` has no branch for it: a schema that still +carries one plots no markers and requests nothing. Without a `DataSource` it fails one step earlier, with `DataSource required for object/api providers`. +A metadata-declared API call is the page element's `dataSource` binding. Read from the database with the **Object Provider** above, or pass records you already hold with the **Value Provider**. diff --git a/packages/core/src/adapters/ApiDataSource.ts b/packages/core/src/adapters/ApiDataSource.ts index c5b4d3354a..4aa2fe5d52 100644 --- a/packages/core/src/adapters/ApiDataSource.ts +++ b/packages/core/src/adapters/ApiDataSource.ts @@ -5,8 +5,11 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * A DataSource adapter for the `provider: 'api'` ViewData mode. - * Makes raw HTTP requests using the HttpRequest configs from ViewData. + * A DataSource adapter over raw HTTP, built in code from an + * `ApiDataSourceConfig` (e.g. the console's approvals inbox). It is not a + * `ViewData` mode: the view-level `provider: 'api'` is retired in + * `@objectstack/spec` (objectstack#23082), and `resolveDataSource` builds no + * `ApiDataSource` (objectui#12325). */ import type { @@ -126,8 +129,9 @@ function mergeHeaders( /** * ApiDataSource — a DataSource adapter for raw HTTP APIs. * - * Used when `ViewData.provider === 'api'`. The read and write HttpRequest - * configs define the endpoints; all CRUD methods map onto HTTP verbs. + * Constructed in code — never resolved from a `ViewData` block (see the file + * header). The read and write HttpRequest configs define the endpoints; all + * CRUD methods map onto HTTP verbs. * * Read operations use the `read` config, write operations use the `write` config. * Both fall back to each other when one is not provided. @@ -172,7 +176,7 @@ export class ApiDataSource implements DataSource { if (!base) { throw new Error( 'ApiDataSource: No HTTP configuration provided for this operation. ' + - 'Ensure the ViewData has read/write configs.', + 'Ensure the ApiDataSourceConfig has read/write configs.', ); } diff --git a/packages/core/src/adapters/README.md b/packages/core/src/adapters/README.md index 21fe105944..626e6c0aee 100644 --- a/packages/core/src/adapters/README.md +++ b/packages/core/src/adapters/README.md @@ -18,9 +18,9 @@ them from `@object-ui/core`. | Export | Kind | Role | | --- | --- | --- | -| `ApiDataSource` | class | `provider: 'api'` — raw HTTP against the `HttpRequest` configs carried in `ViewData` | +| `ApiDataSource` | class | raw HTTP against the `HttpRequest` configs it is constructed with — built in code, never from a `ViewData` block | | `ValueDataSource` | class | `provider: 'value'` — an in-memory array; no network at all | -| `resolveDataSource` | function | builds the adapter a `ViewData` config asks for (`object` / `api` / `value`) | +| `resolveDataSource` | function | builds the adapter a `ViewData` config asks for (`object` / `value`) | | `runBatchTransaction` / `emulateBatchTransaction` | functions | ordered cross-object save — the adapter's own `batchTransaction` when it has one, a sequential non-atomic emulation when it does not | Backend-specific adapters live in their own packages rather than here; today @@ -28,7 +28,11 @@ that is `@object-ui/data-objectstack`. ### `ApiDataSource` -For `provider: 'api'`. The endpoint comes from the `HttpRequest` configs, not +Built in code from an `ApiDataSourceConfig` — the console's approvals inbox is +one such caller. It is not a `ViewData` mode: the view-level `provider: 'api'` +is retired in `@objectstack/spec` (objectstack#23082), so `resolveDataSource` +never builds one, and a metadata-declared API call is the page element's +`dataSource` binding instead. The endpoint comes from the `HttpRequest` configs, not from the `resource` argument — `find`/`findOne` use `read`, and `create`/`update`/`delete` use `write` (falling back to `read` when `write` is absent). `QueryParams` are flattened onto the query string. @@ -273,7 +277,7 @@ import type { DataSource } from '@object-ui/types'; declare const contextDataSource: DataSource; const dataSource = resolveDataSource( - { provider: 'api', read: { url: '/api/users' } }, + { provider: 'value', items: [{ id: '1', name: 'Ada' }] }, contextDataSource, // used for `provider: 'object'`, and as the fallback ); ``` @@ -281,9 +285,8 @@ const dataSource = resolveDataSource( | `viewData.provider` | Result | | --- | --- | | `'object'` | the `fallback` — the `DataSource` from context, typically `ObjectStackAdapter` | -| `'api'` | a new `ApiDataSource` built from `read` / `write` | | `'value'` | a new `ValueDataSource` over `items` | -| unknown, or no `viewData` | the `fallback`, else `null` | +| unknown (the retired `'api'` included), or no `viewData` | the `fallback`, else `null` | ### `runBatchTransaction` / `emulateBatchTransaction` diff --git a/packages/core/src/adapters/__tests__/resolveDataSource.test.ts b/packages/core/src/adapters/__tests__/resolveDataSource.test.ts index 542a34066e..e02c2b6a76 100644 --- a/packages/core/src/adapters/__tests__/resolveDataSource.test.ts +++ b/packages/core/src/adapters/__tests__/resolveDataSource.test.ts @@ -62,37 +62,31 @@ describe('resolveDataSource', () => { }); // ----------------------------------------------------------------------- - // provider: 'api' + // provider: 'api' — retired (objectstack#23082, objectui#12325) // ----------------------------------------------------------------------- - - it('should create ApiDataSource for provider: "api"', () => { - const viewData: ViewData = { - provider: 'api', - read: { url: '/api/contacts' }, - }; - const ds = resolveDataSource(viewData); - expect(ds).toBeInstanceOf(ApiDataSource); - }); - - it('should create ApiDataSource with read and write configs', () => { - const viewData: ViewData = { + // These cases used to pin that an `api` block built an `ApiDataSource` from + // its `read` / `write`. The view-level `api` data source is retired in the + // spec, so the arm is gone: the block is an unknown provider and gets an + // unknown provider's answer. The `ApiDataSource` class itself stays, for + // code that constructs it (ApiDataSource.test.ts pins that). + + it('builds no ApiDataSource for a retired provider: "api" block; it falls back like an unknown provider', () => { + const retired = { provider: 'api', read: { url: '/api/contacts', method: 'GET' }, write: { url: '/api/contacts', method: 'POST' }, - }; - const ds = resolveDataSource(viewData); - expect(ds).toBeInstanceOf(ApiDataSource); + } as unknown as ViewData; + const withFallback = resolveDataSource(retired, mockFallback); + expect(withFallback).toBe(mockFallback); + expect(withFallback).not.toBeInstanceOf(ApiDataSource); + expect(resolveDataSource(retired)).toBeNull(); }); - it('should pass adapter options to ApiDataSource', () => { - const viewData: ViewData = { - provider: 'api', - read: { url: '/api/contacts' }, - }; - const ds = resolveDataSource(viewData, null, { - defaultHeaders: { Authorization: 'Bearer test' }, - }); - expect(ds).toBeInstanceOf(ApiDataSource); + it('answers a retired provider: "api" block exactly as it answers any unknown provider', () => { + const retired = { provider: 'api', read: { url: '/api/contacts' } } as unknown as ViewData; + const unknown = { provider: 'nonexistent' } as unknown as ViewData; + expect(resolveDataSource(retired, mockFallback)).toBe(resolveDataSource(unknown, mockFallback)); + expect(resolveDataSource(retired)).toBe(resolveDataSource(unknown)); }); // ----------------------------------------------------------------------- diff --git a/packages/core/src/adapters/resolveDataSource.ts b/packages/core/src/adapters/resolveDataSource.ts index 861b9785c0..57d3833023 100644 --- a/packages/core/src/adapters/resolveDataSource.ts +++ b/packages/core/src/adapters/resolveDataSource.ts @@ -9,14 +9,9 @@ */ import type { DataSource, ViewData } from '@object-ui/types'; -import { ApiDataSource, type ApiDataSourceConfig } from './ApiDataSource.js'; import { ValueDataSource } from './ValueDataSource.js'; export interface ResolveDataSourceOptions { - /** Custom fetch implementation passed to ApiDataSource */ - fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; - /** Default headers for API requests */ - defaultHeaders?: Record; /** Custom ID field for ValueDataSource */ idField?: string; } @@ -25,9 +20,16 @@ export interface ResolveDataSourceOptions { * Resolve a ViewData configuration into a concrete DataSource instance. * * - `provider: 'object'` → returns `fallback` (the context DataSource — typically ObjectStackAdapter) - * - `provider: 'api'` → returns a new `ApiDataSource` * - `provider: 'value'` → returns a new `ValueDataSource` * + * There is no `provider: 'api'` arm. The view-level `api` data source is + * retired in `@objectstack/spec` (objectstack#23082, ADR-0049): a view binds + * its object or static rows, and a metadata-declared API call is the page + * element's `dataSource` binding. A view block that still says `api` is an + * unknown provider here and gets the same answer as one, so no request is + * ever built from its `read` / `write` (objectui#12325). Code that needs raw + * HTTP constructs `ApiDataSource` itself, from an `ApiDataSourceConfig`. + * * @param viewData - The ViewData configuration from the schema * @param fallback - The default DataSource from context (for `provider: 'object'`) * @param options - Additional options for adapter construction @@ -36,7 +38,7 @@ export interface ResolveDataSourceOptions { * @example * ```ts * const ds = resolveDataSource( - * { provider: 'api', read: { url: '/api/users' } }, + * { provider: 'value', items: [{ id: '1', name: 'Ada' }] }, * contextDataSource, * ); * const result = await ds.find('users'); @@ -56,16 +58,6 @@ export function resolveDataSource( // Delegate to the context DataSource (ObjectStackAdapter, etc.) return fallback ?? null; - case 'api': { - const config: ApiDataSourceConfig = { - read: viewData.read, - write: viewData.write, - fetch: options?.fetch, - defaultHeaders: options?.defaultHeaders, - }; - return new ApiDataSource(config); - } - case 'value': return new ValueDataSource({ items: (viewData.items ?? []) as T[], diff --git a/packages/core/src/data-scope/ViewDataProvider.ts b/packages/core/src/data-scope/ViewDataProvider.ts index 72f7d6889e..ad7a9121f0 100644 --- a/packages/core/src/data-scope/ViewDataProvider.ts +++ b/packages/core/src/data-scope/ViewDataProvider.ts @@ -10,11 +10,14 @@ * @object-ui/core - View Data Provider * * Resolves @objectstack/spec ViewData discriminated union into - * a unified data access interface. Supports three data modes: + * a unified data access interface. Supports two data modes: * * 1. 'object' — Metadata-driven via ObjectStack API (object name → fields/records) - * 2. 'api' — Custom REST API endpoints (read/write URLs) - * 3. 'value' — Static data (pre-loaded items array) + * 2. 'value' — Static data (pre-loaded items array) + * + * The view-level `'api'` mode is retired in `@objectstack/spec` + * (objectstack#23082, ADR-0049) and has no arm here (objectui#12325): a + * config that still names it resolves as an unknown provider, with an error. * * @module data-scope * @packageDocumentation @@ -33,11 +36,6 @@ import { /** ViewData union — matches @objectstack/spec ViewDataSchema */ export type ViewDataConfig = | { provider: 'object'; object: string } - | { - provider: 'api'; - read: { url: string; method?: string; headers?: Record }; - write?: { url: string; method?: string; headers?: Record }; - } | { provider: 'value'; items: any[] }; /** @@ -73,16 +71,6 @@ export interface DataFetcher { }>; }>; - /** Fetch from a custom API URL */ - fetchUrl?( - url: string, - options?: { - method?: string; - headers?: Record; - body?: any; - }, - ): Promise; - /** * Fetch the object's saved views, so an `ElementDataSource.view` name can be * resolved to real columns / filter / sort (objectstack#5576). @@ -118,22 +106,13 @@ export interface ResolvedData { }>; }; /** Provider type */ - provider: 'object' | 'api' | 'value'; + provider: 'object' | 'value'; /** Loading state */ loading: boolean; /** Error */ error?: string; } -/** Extract records array from various common API response shapes */ -function extractRecords(data: any): any[] { - if (Array.isArray(data)) return data; - if (Array.isArray(data?.records)) return data.records; - if (Array.isArray(data?.data)) return data.data; - if (Array.isArray(data?.items)) return data.items; - return []; -} - /** * Resolves ViewData configuration into actual data via a pluggable fetcher. * @@ -169,8 +148,6 @@ export class ViewDataProvider { switch (config.provider) { case 'value': return this.resolveValue(config); - case 'api': - return this.resolveApi(config); case 'object': return this.resolveObject(config, options); default: @@ -198,48 +175,6 @@ export class ViewDataProvider { }; } - /** Resolve API-based data */ - private async resolveApi(config: { - provider: 'api'; - read: { url: string; method?: string; headers?: Record }; - }): Promise { - if (!this.fetcher?.fetchUrl) { - return { - records: [], - total: 0, - provider: 'api', - loading: false, - error: 'No fetchUrl implementation available for API data provider', - }; - } - - try { - const data = await this.fetcher.fetchUrl(config.read.url, { - method: config.read.method, - headers: config.read.headers, - }); - - // Handle common response shapes - const records = extractRecords(data); - const total = data?.total ?? data?.count ?? records.length; - - return { - records, - total, - provider: 'api', - loading: false, - }; - } catch (error) { - return { - records: [], - total: 0, - provider: 'api', - loading: false, - error: (error as Error).message, - }; - } - } - /** Resolve object-based data (metadata-driven) */ private async resolveObject( config: { provider: 'object'; object: string }, diff --git a/packages/core/src/data-scope/__tests__/ViewDataProvider.test.ts b/packages/core/src/data-scope/__tests__/ViewDataProvider.test.ts index 9c89b0e448..f498800a84 100644 --- a/packages/core/src/data-scope/__tests__/ViewDataProvider.test.ts +++ b/packages/core/src/data-scope/__tests__/ViewDataProvider.test.ts @@ -58,89 +58,28 @@ describe('ViewDataProvider', () => { }); }); - // ===== API Provider ===== - describe('api provider', () => { - it('returns error when no fetchUrl implementation', async () => { - const config: ViewDataConfig = { - provider: 'api', - read: { url: 'https://api.example.com/records' }, - }; - - const result = await provider.resolve(config); - expect(result.error).toContain('No fetchUrl implementation'); - }); - - it('fetches data from API URL', async () => { + // ===== Retired API provider (objectui#12325) ===== + // The view-level `api` data source is retired in @objectstack/spec + // (objectstack#23082). It used to resolve through `fetcher.fetchUrl`; it now + // has no arm, so a config that still names it is an unknown provider: an + // error naming it, no records, and nothing fetched on its behalf. + describe('retired api provider', () => { + it('resolves as an unknown provider and fetches nothing', async () => { const fetcher: DataFetcher = { - fetchRecords: vi.fn(), - fetchUrl: vi.fn().mockResolvedValue({ - records: [{ id: 1 }], - total: 1, - }), + fetchRecords: vi.fn().mockResolvedValue({ records: [{ id: 1 }], total: 1 }), }; provider.setFetcher(fetcher); - const result = await provider.resolve({ + const retired = { provider: 'api', read: { url: 'https://api.example.com/data', method: 'GET' }, - }); - - expect(result.records).toHaveLength(1); - expect(result.total).toBe(1); - expect(fetcher.fetchUrl).toHaveBeenCalledWith( - 'https://api.example.com/data', - { - method: 'GET', - headers: undefined, - }, - ); - }); - - it('handles array response shape', async () => { - const fetcher: DataFetcher = { - fetchRecords: vi.fn(), - fetchUrl: vi.fn().mockResolvedValue([{ id: 1 }, { id: 2 }]), - }; - provider.setFetcher(fetcher); - - const result = await provider.resolve({ - provider: 'api', - read: { url: 'https://api.example.com/data' }, - }); + } as unknown as ViewDataConfig; + const result = await provider.resolve(retired); - expect(result.records).toHaveLength(2); - expect(result.total).toBe(2); - }); - - it('handles { data: [] } response shape', async () => { - const fetcher: DataFetcher = { - fetchRecords: vi.fn(), - fetchUrl: vi.fn().mockResolvedValue({ data: [{ id: 1 }] }), - }; - provider.setFetcher(fetcher); - - const result = await provider.resolve({ - provider: 'api', - read: { url: 'https://api.example.com/data' }, - }); - - expect(result.records).toHaveLength(1); - }); - - it('handles API errors gracefully', async () => { - const fetcher: DataFetcher = { - fetchRecords: vi.fn(), - fetchUrl: vi.fn().mockRejectedValue(new Error('Network error')), - }; - provider.setFetcher(fetcher); - - const result = await provider.resolve({ - provider: 'api', - read: { url: 'https://api.example.com/data' }, - }); - - expect(result.error).toBe('Network error'); - expect(result.records).toHaveLength(0); + expect(result.error).toBe('Unknown data provider: api'); + expect(result.records).toEqual([]); + expect(result.total).toBe(0); + expect(fetcher.fetchRecords).not.toHaveBeenCalled(); }); }); diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 11f86ffe0d..26ee66489b 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -851,8 +851,7 @@ export const ObjectCalendar: React.FC = ({ // Falling through here would therefore throw // `DataSource required for object/api providers` on a calendar that // needs no DataSource at all. So the adapter is resolved for the - // inline provider ONLY — `api` keeps exactly the behaviour it had — - // and the same three keys are lowered onto the same query shape. + // inline provider ONLY, and the same three keys are lowered onto the same query shape. // // Built here rather than memoised at render scope so this effect goes // on reading only the primitive fields objectui#6592 named @@ -976,13 +975,10 @@ export const ObjectCalendar: React.FC = ({ // a run starts: until rows land, the report stays. setError(null); } - } else if (dataProvider === 'api') { - console.warn('API provider not yet implemented for ObjectCalendar'); - if (isMounted) { - setData([]); - setError(null); - } } + // No `api` arm: the view-level `api` data source is retired in + // `@objectstack/spec` (objectstack#23082) and read by nothing here + // (objectui#12325). if (isMounted) setLoading(false); } catch (err) { diff --git a/packages/plugin-gantt/README.md b/packages/plugin-gantt/README.md index 73e2a8c0d7..b418ab84f1 100644 --- a/packages/plugin-gantt/README.md +++ b/packages/plugin-gantt/README.md @@ -287,11 +287,15 @@ const recordSource = { }; ``` -`data` is the spec's `ViewData` union. This chart reads three of its arms: -`{ provider: 'object', object }`, `{ provider: 'value', items }` and -`{ provider: 'api', read, write }`. The fourth, `{ provider: 'schema', schemaId }`, -is accepted by the spec and is not a source this chart reads: measured, a gantt -carrying it queries `objectName` instead. +`data` is the spec's `ViewData` union. This chart reads two of its arms: +`{ provider: 'object', object }` and `{ provider: 'value', items }`. +`{ provider: 'schema', schemaId }` is accepted by the spec and is not a source +this chart reads: measured, a gantt carrying it queries `objectName` instead. +The view-level `{ provider: 'api', read, write }` arm is retired in the spec +(objectstack#23082) and nothing here reads it either: a gantt still carrying +one is answered the same way, and its `read` / `write` URLs are never +requested. A metadata-declared API call is the page element's `dataSource` +binding. **The provider does not change which query keys apply.** An authored `filter` and `sort` narrow and order the rows on **every** provider, inline ones diff --git a/packages/plugin-gantt/src/ObjectGantt.apifetch.test.tsx b/packages/plugin-gantt/src/ObjectGantt.apifetch.test.tsx deleted file mode 100644 index 37b1ceca4a..0000000000 --- a/packages/plugin-gantt/src/ObjectGantt.apifetch.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/** - * provider:'api' must use the host-authenticated fetch (#2725). - * - * The composite endpoint behind an api-provider gantt is a platform endpoint - * like any other — its requests must carry the host's session credentials - * (Authorization / tenant headers), not ride on cookies alone. The host - * publishes an authenticated fetch via SchemaRendererContext.apiFetch; - * ObjectGantt must thread it into resolveDataSource so ApiDataSource uses it - * instead of the bare global fetch. - */ -import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { SchemaRendererProvider } from '@object-ui/react'; -import { ObjectGantt } from './ObjectGantt'; - -vi.mock('sonner', () => ({ toast: { error: vi.fn() } })); - -vi.mock('./GanttView', () => ({ - GanttView: ({ tasks }: any) => ( -
- {tasks.map((t: any) => ( -
{t.title}
- ))} -
- ), -})); - -vi.mock('@object-ui/plugin-detail', async (importOriginal) => ({ - ...(await importOriginal()), - RecordDetailDrawer: () => null, - deriveRecordPageHref: () => null, -})); - -const ROWS = [ - { id: '1', name: 'Task A', start_date: '2024-01-01', end_date: '2024-01-05' }, -]; - -function makeSchema(): any { - return { - type: 'gantt', - objectName: 'tasks', - gantt: { - titleField: 'name', - startDateField: 'start_date', - endDateField: 'end_date', - }, - data: { provider: 'api', read: { url: '/api/gantt/tree', method: 'GET' } }, - }; -} - -function makeApiFetch() { - return vi.fn(async () => - new Response(JSON.stringify({ data: ROWS, total: ROWS.length }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); -} - -describe("ObjectGantt provider:'api' host auth fetch", () => { - const bareFetch = vi.fn(async () => new Response('{}', { status: 401 })); - - beforeEach(() => { - bareFetch.mockClear(); - vi.stubGlobal('fetch', bareFetch); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('reads through SchemaRendererContext.apiFetch, not the bare global fetch', async () => { - const apiFetch = makeApiFetch(); - - render( - - - , - ); - - await waitFor(() => expect(screen.getByTestId('gv-view-1')).toBeDefined()); - expect(apiFetch).toHaveBeenCalled(); - const [calledUrl] = apiFetch.mock.calls[0] as unknown as [string]; - expect(String(calledUrl)).toContain('/api/gantt/tree'); - expect(bareFetch).not.toHaveBeenCalled(); - }); - - it('falls back to the global fetch when no apiFetch is in context', async () => { - bareFetch.mockImplementation(async () => - new Response(JSON.stringify({ data: ROWS, total: ROWS.length }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - - render(); - - await waitFor(() => expect(screen.getByTestId('gv-view-1')).toBeDefined()); - expect(bareFetch).toHaveBeenCalled(); - }); -}); diff --git a/packages/plugin-gantt/src/ObjectGantt.discardedReloadIdentity-10036.test.tsx b/packages/plugin-gantt/src/ObjectGantt.discardedReloadIdentity-10036.test.tsx index 1e9d2b1870..d8414110f7 100644 --- a/packages/plugin-gantt/src/ObjectGantt.discardedReloadIdentity-10036.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.discardedReloadIdentity-10036.test.tsx @@ -15,10 +15,11 @@ * * 1. `reload`'s dependency list named `effectiveDataSource`, a `useMemo` * result. `resolveDataSource` returns the context adapter UNCHANGED for - * `provider: 'object'`, but CONSTRUCTS a new `ApiDataSource` / - * `ValueDataSource` for `provider: 'api'` and `'value'` — so on those two - * providers a discarded memo hands back a fresh adapter for a byte- - * identical config, and `reload` is rebuilt for no authored reason. + * `provider: 'object'`, but CONSTRUCTS a new `ValueDataSource` for + * `provider: 'value'` (and, until objectui#12325 retired that arm, a new + * `ApiDataSource` for `'api'`) — so there a discarded memo hands back a + * fresh adapter for a byte-identical config, and `reload` is rebuilt for + * no authored reason. * 2. The mount effect named `reload`, a `useCallback` result — and a * discarded `useCallback` yields a fresh function whatever its own * dependency list says, INCLUDING an empty one. So fixing (1) alone @@ -48,24 +49,26 @@ * would leave `reload`'s own `useCallback` un-discarded, which is exactly the * half of the seam (2) names. * - * ⚠️ THE OBSERVABLE IS A REQUEST COUNT, NOT AN IDENTITY COMPARE. An identity - * comparison can hold while the effect re-runs for some other reason. Requests - * are served by a double passed as `SchemaRendererProvider`'s `apiFetch`, so - * nothing here reaches happy-dom's `http://localhost:3000` and is attributed - * to this file by the network-escape guard (objectui#8537). + * ⚠️ THE OBSERVABLE IS A READ COUNT, NOT AN IDENTITY COMPARE. An identity + * comparison can hold while the effect re-runs for some other reason. Reads + * are counted on `ValueDataSource.prototype.find`, which every adapter the + * memo constructs shares, so a fresh adapter is counted like the first one. * - * ⚠️ THE PROVIDER IS `'api'` ON PURPOSE. `ObjectGantt.discardedConfigMemo.test.tsx` + * ⚠️ THE PROVIDER IS `'value'` ON PURPOSE. `ObjectGantt.discardedConfigMemo.test.tsx` * records why an `object`-provider pin cannot see this: `resolveDataSource` * answers that provider with the `fallback` context DataSource UNCHANGED, so * `effectiveDataSource`'s VALUE is referentially stable there even when its * memo reruns. That file names the gap it leaves as a "Known boundary" for the - * `api` / `value` providers. This is that boundary, measured. + * constructing providers. This is that boundary, measured. It was measured on + * the `'api'` provider until the view-level `api` data source was retired in + * `@objectstack/spec` (objectstack#23082, objectui#12325); `'value'` is the + * constructing provider that remains. */ import React from 'react'; import { render, act, cleanup, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, afterEach } from 'vitest'; -import { SchemaRendererProvider } from '@object-ui/react'; +import { ValueDataSource } from '@object-ui/core'; import type { DataSource } from '@object-ui/types'; import { ObjectGantt } from './ObjectGantt'; @@ -142,40 +145,39 @@ const GANTT = { titleField: 'name', startDateField: 'start_date', endDateField: /** * ⛔ Deliberately NO `objectName`. `resolveRecordSourceObjectName` answers * `schema.objectName` for every non-`object` provider, so naming one here would - * give `useSettledSchema` a key to read and put a SECOND fetch — the object - * schema read, whose own effect keys on the adapter this card is about — into - * the numbers below. With no key that hook settles immediately with `def: null` - * on every render, so `objectSchemaReady` / `objectSchema` are constants here - * and the record fetch is the only thing being counted. + * give `useSettledSchema` a key to read and put a SECOND read into the numbers + * below. With no key that hook settles immediately with `def: null` on every + * render, so `objectSchemaReady` / `objectSchema` are constants here and the + * record read is the only thing being counted. */ -function apiSchema(url = '/api/gantt/tasks', extra: Record = {}): any { +function valueSchema(items: unknown[] = ROWS, extra: Record = {}): any { return { type: 'gantt', gantt: GANTT, - data: { provider: 'api', read: { url, method: 'GET' } }, + data: { provider: 'value', items }, ...extra, }; } /** - * A fetch double that records WHICH double ran and WHICH endpoint it was asked - * for. The query string is dropped from the record on purpose: `reload` lowers - * the platform row ceiling into `$top` on every read (objectui#7210), so - * keeping it would pin that unrelated ruling's number in this file. + * Count every `find` any `ValueDataSource` serves, recording the first row's + * id so a read of NEW items is distinguishable from a re-read of the old ones. */ -function makeApiFetch(log: string[], tag = 'A') { - return vi.fn(async (input: RequestInfo | URL) => { - log.push(`${tag} ${String(input).split('?')[0]}`); - return new Response(JSON.stringify({ data: ROWS, total: ROWS.length }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - }) as any; +function countValueReads(log: string[]) { + const real = ValueDataSource.prototype.find; + return vi.spyOn(ValueDataSource.prototype, 'find').mockImplementation(async function ( + this: ValueDataSource, + ...args: Parameters + ) { + const result = await real.apply(this, args); + log.push(`read ${String((result.data?.[0] as any)?.id ?? '-')}`); + return result; + }); } /** - * Give a re-fired effect a real turn to issue its request. Asserting the - * ABSENCE of a round trip needs a settle window; `waitFor` cannot express one. + * Give a re-fired effect a real turn to issue its read. Asserting the + * ABSENCE of a read needs a settle window; `waitFor` cannot express one. */ const settle = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); }); @@ -208,30 +210,39 @@ describe('the discard proxy really reaches the bindings ObjectGantt uses (object }); }); -describe("ObjectGantt provider:'api' — the record fetch survives a discarded cache (objectui#10036)", () => { - it('costs no redundant round trip when a discard rebuilds the adapter and the callback, config unchanged', async () => { +describe("ObjectGantt provider:'value' — the record read survives a discarded cache (objectui#10036)", () => { + let readSpy: ReturnType | undefined; + afterEach(() => { + readSpy?.mockRestore(); + readSpy = undefined; + }); + + it('costs no redundant read when a discard rebuilds the adapter and the callback, config unchanged', async () => { const log: string[] = []; - const apiFetch = makeApiFetch(log); - const schema = apiSchema(); - const tree = () => ( - - - - ); + readSpy = countValueReads(log); + const schema = valueSchema(); + const tree = () => ; const restore = armDiscardProxy(); + let mounted = 0; try { const { rerender } = render(tree()); - await waitFor(() => expect(log).toHaveLength(1)); + await waitFor(() => expect(log.length).toBeGreaterThan(0)); + await settle(); + // The baseline is MEASURED, not assumed: an inline-provider gantt reads + // its items more than once while it mounts, for a reason outside this + // card (see the PR body of objectui#12325). What this pin owns is that + // nothing after the mount adds a read. + mounted = log.length; - // Armed but NOT fired: an ordinary re-render must not refetch either. + // Armed but NOT fired: an ordinary re-render must not re-read either. rerender(tree()); await settle(); - expect(log).toHaveLength(1); + expect(log).toHaveLength(mounted); // Two discards, each followed by a re-render with the SAME authored - // config, the SAME `apiFetch` and the SAME (absent) context adapter. - // Nothing an author or a host controls has changed. + // config and the SAME (absent) context adapter. Nothing an author or a + // host controls has changed. discardNow(); rerender(tree()); await settle(); @@ -242,79 +253,56 @@ describe("ObjectGantt provider:'api' — the record fetch survives a discarded c restore(); } - // THE observable this card names: a request count, not an identity compare. - expect(log).toEqual(['A /api/gantt/tasks']); + // THE observable this card names: a read count, not an identity compare. + expect(log).toHaveLength(mounted); }); /** * The non-regression axis, taken from the plausible WRONG fix rather than * from the defect's shape: dropping the dependency altogether would also - * stop the redundant round trip — and would stop every LEGITIMATE refetch - * with it. These pins fail on such a fix. Together they cover the authored - * config, the host's fetch and the host's adapter, which is the parity - * claim: the fix removes React's licence to re-fire and narrows nothing - * else. (`resource`, `dataProvider`, `hasInlineData` are all DERIVED from - * the authored config the first two pins move; `objectSchema` and `perms` - * are unchanged members of the same list.) + * stop the redundant read — and would stop every LEGITIMATE re-read with + * it. These pins fail on such a fix. Together with the `object`-provider + * block below they cover the authored config and the host's adapter, which + * is the parity claim: the fix removes React's licence to re-fire and + * narrows nothing else. (`resource`, `dataProvider`, `hasInlineData` are all + * DERIVED from the authored config the two pins here move; `objectSchema` + * and `perms` are unchanged members of the same list.) */ - it('still refetches when the authored endpoint genuinely changes', async () => { + it('still re-reads when the authored items genuinely change', async () => { const log: string[] = []; - const apiFetch = makeApiFetch(log); - const tree = (schema: any) => ( - - - - ); - - const { rerender } = render(tree(apiSchema('/api/gantt/v1'))); - await waitFor(() => expect(log).toHaveLength(1)); + readSpy = countValueReads(log); + const tree = (schema: any) => ; + const NEXT = [{ ...ROWS[0], id: '2', name: 'Task B' }]; - rerender(tree(apiSchema('/api/gantt/v2'))); - await waitFor(() => expect(log).toHaveLength(2)); + const { rerender } = render(tree(valueSchema(ROWS))); + await waitFor(() => expect(log.length).toBeGreaterThan(0)); await settle(); - expect(log).toEqual(['A /api/gantt/v1', 'A /api/gantt/v2']); - }); + const mounted = log.length; + expect(log.every((e) => e === 'read 1')).toBe(true); - it('still refetches when the authored filter genuinely changes', async () => { - const log: string[] = []; - const apiFetch = makeApiFetch(log); - const tree = (schema: any) => ( - - - - ); - - const { rerender } = render(tree(apiSchema('/api/gantt/tasks', { filter: [['status', '=', 'open']] }))); - await waitFor(() => expect(log).toHaveLength(1)); - - rerender(tree(apiSchema('/api/gantt/tasks', { filter: [['status', '=', 'done']] }))); - await waitFor(() => expect(log).toHaveLength(2)); + rerender(tree(valueSchema(NEXT))); + await waitFor(() => expect(log.length).toBeGreaterThan(mounted)); await settle(); - expect(log).toHaveLength(2); + // Not merely "a read happened": the NEW items are what was read. + expect(log.slice(mounted).every((e) => e === 'read 2')).toBe(true); }); - it('still refetches when the host swaps the authenticated fetch itself', async () => { - // ⚠️ Two CONCRETE, distinguishable doubles. Swapping one `undefined` for - // another would prove nothing: it is green on a fix that never refetches at - // all, because the two compare equal. + it('still re-reads when the authored filter genuinely changes', async () => { const log: string[] = []; - const fetchA = makeApiFetch(log, 'A'); - const fetchB = makeApiFetch(log, 'B'); - const schema = apiSchema(); - const tree = (f: any) => ( - - - - ); - - const { rerender } = render(tree(fetchA)); - await waitFor(() => expect(log).toHaveLength(1)); + readSpy = countValueReads(log); + const tree = (schema: any) => ; + + const { rerender } = render(tree(valueSchema(ROWS, { filter: [['name', '=', 'Task A']] }))); + await waitFor(() => expect(log.length).toBeGreaterThan(0)); + await settle(); + const mounted = log.length; + expect(log.every((e) => e === 'read 1')).toBe(true); - rerender(tree(fetchB)); - // Not merely "a request happened": the NEW fetch must be the one that runs. - await waitFor(() => expect(log).toHaveLength(2)); + rerender(tree(valueSchema(ROWS, { filter: [['name', '!=', 'Task A']] }))); + await waitFor(() => expect(log.length).toBeGreaterThan(mounted)); await settle(); - expect(log).toEqual(['A /api/gantt/tasks', 'B /api/gantt/tasks']); + // The new filter excludes the only row, so the re-read answers nothing. + expect(log.slice(mounted).every((e) => e === 'read -')).toBe(true); }); }); diff --git a/packages/plugin-gantt/src/ObjectGantt.drawerfetch.test.tsx b/packages/plugin-gantt/src/ObjectGantt.drawerfetch.test.tsx index df444bbb97..db8183ce97 100644 --- a/packages/plugin-gantt/src/ObjectGantt.drawerfetch.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.drawerfetch.test.tsx @@ -1,10 +1,9 @@ /** * Drawer must show the real record, not the composed row (#2473 第 1 项). * - * With `provider: 'api'` the rows are render payloads (bar colors, sort keys, - * node types…) composed by the endpoint — not business records. And with - * `objectField` a row can belong to a different object than the bound one, - * for which no schema is loaded. Rendering that raw payload in the drawer + * Rows can be render payloads (bar colors, sort keys, node types…) rather + * than business records, and with `objectField` a row can belong to a + * different object than the bound one, for which no schema is loaded. Rendering that raw payload in the drawer * degrades every field to a humanized English label. When a context * DataSource is available, ObjectGantt must fetch the actual record (and the * foreign object's schema) and hand THAT to the drawer; the raw row stays as @@ -51,7 +50,7 @@ vi.mock('@object-ui/plugin-detail', async (importOriginal) => ({ deriveRecordPageHref: () => null, })); -// Composed rows as an api endpoint would return them: render-only keys, and +// Composed rows: render-only keys, and // row 1 belongs to a foreign object (`object_name` ≠ bound object). Row // `grp_1` is a synthetic group header — no object_name, its id is not a real // record id. diff --git a/packages/plugin-gantt/src/ObjectGantt.emptyHostData-7333.test.tsx b/packages/plugin-gantt/src/ObjectGantt.emptyHostData-7333.test.tsx index cca0071e44..c9353a835c 100644 --- a/packages/plugin-gantt/src/ObjectGantt.emptyHostData-7333.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.emptyHostData-7333.test.tsx @@ -28,18 +28,24 @@ * spreads the prop in, as a direct caller would have to. * * Cases 1 and 4 are the pins; 2 and 3 are their controls, and 5 is the "yet": - * 1. api provider + host `[]` ⇒ the endpoint is read and its rows paint; - * 2. api provider + host rows ⇒ those rows paint, the endpoint is never read; - * 3. api provider + no host `data` ⇒ the endpoint is read; + * 1. own source + host `[]` ⇒ the source is read and its rows paint; + * 2. own source + host rows ⇒ those rows paint, the source is never read; + * 3. own source + no host `data` ⇒ the source is read; * 4. object provider + host `[]` ⇒ the query waits for the object schema and * goes out ONCE, already expanded (the gating flag's half); * 5. host rows that arrive after an empty first render are adopted. + * + * Cases 1–3 and 5 used to name an `api`-provider gantt's endpoint as its own + * source. The view-level `api` data source is retired in `@objectstack/spec` + * (objectstack#23082) and ObjectGantt builds no adapter from it + * (objectui#12325), so the own source here is the bound object, read through + * the context adapter — the predicate under test does not depend on which + * provider supplies it. */ import React from 'react'; import { render, screen, waitFor, act } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { SchemaRendererProvider } from '@object-ui/react'; +import { describe, it, expect, vi } from 'vitest'; import { ObjectGantt } from './ObjectGantt'; vi.mock('sonner', () => ({ toast: { error: vi.fn() } })); @@ -60,10 +66,10 @@ vi.mock('@object-ui/plugin-detail', async (importOriginal) => ({ deriveRecordPageHref: () => null, })); -/** What the gantt's own `api` endpoint answers with. */ -const ENDPOINT_ROWS = [ - { id: 'e1', name: 'Endpoint task 1', start_date: '2026-01-01', end_date: '2026-01-05' }, - { id: 'e2', name: 'Endpoint task 2', start_date: '2026-01-02', end_date: '2026-01-06' }, +/** What the gantt's own source (the bound object) answers with. */ +const SOURCE_ROWS = [ + { id: 'e1', name: 'Source task 1', start_date: '2026-01-01', end_date: '2026-01-05' }, + { id: 'e2', name: 'Source task 2', start_date: '2026-01-02', end_date: '2026-01-06' }, ]; /** What a host hands down as `data` when it DOES have rows. */ @@ -71,37 +77,29 @@ const HOST_ROWS = [ { id: 'h1', name: 'Host task 1', start_date: '2026-02-01', end_date: '2026-02-03' }, ]; -const ENDPOINT = '/api/gantt/tree'; - -function apiSchema(): any { +function ownSourceSchema(): any { return { type: 'object-gantt', objectName: 'tasks', gantt: { titleField: 'name', startDateField: 'start_date', endDateField: 'end_date' }, - data: { provider: 'api', read: { url: ENDPOINT, method: 'GET' } }, }; } -function makeApiFetch() { - return vi.fn(async () => - new Response(JSON.stringify({ data: ENDPOINT_ROWS, total: ENDPOINT_ROWS.length }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); -} - -/** The URLs the endpoint was read at, in order. */ -function readUrls(apiFetch: ReturnType): string[] { - return (apiFetch.mock.calls as unknown as Array<[unknown]>).map(([url]) => String(url)); +function makeSource() { + return { + find: vi.fn(async () => ({ data: SOURCE_ROWS, total: SOURCE_ROWS.length })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: 'tasks', fields: {} })), + } as any; } -/** Renders an api-provider gantt, spreading `hostProps` in as a direct caller would. */ -function renderApiGantt(apiFetch: ReturnType, hostProps: Record) { +/** Renders an object-bound gantt, spreading `hostProps` in as a direct caller would. */ +function renderOwnSourceGantt(source: ReturnType, hostProps: Record) { const tree = (props: Record) => ( - - - + ); const utils = render(tree(hostProps)); return { ...utils, rerenderWith: (props: Record) => utils.rerender(tree(props)) }; @@ -112,54 +110,42 @@ const taskCount = () => screen.getByTestId('gantt-view').getAttribute('data-task /** Lets every in-flight read settle, so a query that would follow is counted. */ const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 100))); -describe('objectui#7333 — an api-provider gantt handed an EMPTY host array still reads its endpoint', () => { - // No test here may reach the network through the bare global fetch. - const bareFetch = vi.fn(async () => new Response('{}', { status: 599 })); +describe('objectui#7333 — a gantt handed an EMPTY host array still reads its own source', () => { + it('host `data: []` ⇒ the source is read and its rows paint', async () => { + const source = makeSource(); - beforeEach(() => { - bareFetch.mockClear(); - vi.stubGlobal('fetch', bareFetch); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('host `data: []` ⇒ the endpoint is read and its rows paint', async () => { - const apiFetch = makeApiFetch(); - - renderApiGantt(apiFetch, { data: [] }); + renderOwnSourceGantt(source, { data: [] }); await waitFor(() => expect(screen.getByTestId('gv-task-e2')).toBeTruthy()); - expect(taskCount()).toBe(String(ENDPOINT_ROWS.length)); - expect(readUrls(apiFetch).some((url) => url.includes(ENDPOINT))).toBe(true); - expect(bareFetch).not.toHaveBeenCalled(); + expect(taskCount()).toBe(String(SOURCE_ROWS.length)); + expect(source.find).toHaveBeenCalledWith('tasks', expect.anything()); }); - it('CONTROL — host `data` with rows ⇒ those rows paint and the endpoint is never read', async () => { - const apiFetch = makeApiFetch(); + it('CONTROL — host `data` with rows ⇒ those rows paint and the source is never read', async () => { + const source = makeSource(); - renderApiGantt(apiFetch, { data: HOST_ROWS }); + renderOwnSourceGantt(source, { data: HOST_ROWS }); await waitFor(() => expect(screen.getByTestId('gv-task-h1')).toBeTruthy()); + await settle(); expect(taskCount()).toBe(String(HOST_ROWS.length)); expect(screen.queryByTestId('gv-task-e1')).toBeNull(); - expect(apiFetch).not.toHaveBeenCalled(); + expect(source.find).not.toHaveBeenCalled(); }); - it('CONTROL — no host `data` ⇒ the endpoint is read', async () => { - const apiFetch = makeApiFetch(); + it('CONTROL — no host `data` ⇒ the source is read', async () => { + const source = makeSource(); - renderApiGantt(apiFetch, {}); + renderOwnSourceGantt(source, {}); await waitFor(() => expect(screen.getByTestId('gv-task-e2')).toBeTruthy()); - expect(readUrls(apiFetch).some((url) => url.includes(ENDPOINT))).toBe(true); + expect(source.find).toHaveBeenCalledWith('tasks', expect.anything()); }); it('host rows that arrive after an empty first render are adopted', async () => { - const apiFetch = makeApiFetch(); + const source = makeSource(); - const { rerenderWith } = renderApiGantt(apiFetch, { data: [] }); + const { rerenderWith } = renderOwnSourceGantt(source, { data: [] }); await waitFor(() => expect(screen.getByTestId('gv-task-e2')).toBeTruthy()); rerenderWith({ data: HOST_ROWS }); diff --git a/packages/plugin-gantt/src/ObjectGantt.retiredApiProvider-12325.test.tsx b/packages/plugin-gantt/src/ObjectGantt.retiredApiProvider-12325.test.tsx new file mode 100644 index 0000000000..9ae88bdfac --- /dev/null +++ b/packages/plugin-gantt/src/ObjectGantt.retiredApiProvider-12325.test.tsx @@ -0,0 +1,142 @@ +/** + * 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. + */ + +/** + * objectui#12325 — a gantt whose `data` still says `provider: 'api'` is not + * read through it. + * + * This file used to be `ObjectGantt.apifetch.test.tsx`, which pinned that an + * api-provider gantt fetched its `read.url` through the host's + * `SchemaRendererContext.apiFetch` (#2725), falling back to the global fetch. + * The view-level `api` data source is retired in `@objectstack/spec` + * (objectstack#23082, ADR-0049): a view binds its object or static rows, and a + * metadata-declared API call is the page element's `dataSource` binding. + * `resolveDataSource` has no `api` arm any more, so the block is an unknown + * provider: + * + * 1. its `read` URL is requested through NEITHER channel — not the host's + * `apiFetch`, not the global fetch — and with no DataSource no row is + * drawn; + * 2. with a context DataSource the chart answers it the way it answers any + * unknown provider: it queries `schema.objectName` through that adapter. + */ +import React from 'react'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SchemaRendererProvider } from '@object-ui/react'; +import type { DataSource } from '@object-ui/types'; +import { ObjectGantt } from './ObjectGantt'; + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })); + +vi.mock('./GanttView', () => ({ + GanttView: ({ tasks }: any) => ( +
+ {tasks.map((t: any) => ( +
{t.title}
+ ))} +
+ ), +})); + +vi.mock('@object-ui/plugin-detail', async (importOriginal) => ({ + ...(await importOriginal()), + RecordDetailDrawer: () => null, + deriveRecordPageHref: () => null, +})); + +const ROWS = [ + { id: '1', name: 'Task A', start_date: '2024-01-01', end_date: '2024-01-05' }, +]; + +const ENDPOINT = '/api/gantt/tree'; + +function retiredApiSchema(): any { + return { + type: 'gantt', + objectName: 'tasks', + gantt: { + titleField: 'name', + startDateField: 'start_date', + endDateField: 'end_date', + }, + data: { provider: 'api', read: { url: ENDPOINT, method: 'GET' } }, + }; +} + +function makeFetch() { + return vi.fn(async () => + new Response(JSON.stringify({ data: ROWS, total: ROWS.length }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); +} + +function makeContextDS(): DataSource { + return { + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: 'tasks', fields: {} })), + } as unknown as DataSource; +} + +/** Lets every in-flight read settle, so a request that would follow is counted. */ +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 100))); + +describe("ObjectGantt — a retired provider:'api' block (objectui#12325)", () => { + const bareFetch = makeFetch(); + + beforeEach(() => { + bareFetch.mockClear(); + vi.stubGlobal('fetch', bareFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('requests its read URL through neither the host apiFetch nor the global fetch', async () => { + const apiFetch = makeFetch(); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + render( + + + , + ); + await settle(); + } finally { + errSpy.mockRestore(); + } + + expect(apiFetch).not.toHaveBeenCalled(); + expect(bareFetch).not.toHaveBeenCalled(); + expect(screen.queryByTestId('gv-view-1')).toBeNull(); + }); + + it('with a context DataSource, queries schema.objectName like any unknown provider, and still requests no URL', async () => { + const apiFetch = makeFetch(); + const ds = makeContextDS(); + + render( + + + , + ); + + await waitFor(() => expect(screen.getByTestId('gv-view-1')).toBeDefined()); + expect((ds.find as any).mock.calls.map((call: unknown[]) => call[0])).toEqual(['tasks']); + expect(apiFetch).not.toHaveBeenCalled(); + expect(bareFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-gantt/src/ObjectGantt.test.tsx b/packages/plugin-gantt/src/ObjectGantt.test.tsx index 6dcdabaa2f..6ff0709e69 100644 --- a/packages/plugin-gantt/src/ObjectGantt.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.test.tsx @@ -372,66 +372,12 @@ describe('ObjectGantt', () => { expect(del.mock.calls[0][1]).toBe('1'); }); - // --- API provider: read + write-back over HTTP (no object backend) -------- - it('api provider: reads via fetch and persists a drag update via write config', async () => { - // A Response-like stub carrying JSON. - const jsonResponse = (payload: unknown) => ({ - ok: true, - status: 200, - statusText: 'OK', - headers: { get: (k: string) => (k.toLowerCase() === 'content-type' ? 'application/json' : null) }, - json: async () => payload, - text: async () => JSON.stringify(payload), - }); - const fetchMock = vi.fn(async (_url: any, init?: any) => { - // GET → read config returns the task list; anything else → write ack. - if ((init?.method ?? 'GET') === 'GET') return jsonResponse({ data: mockData }) as any; - return jsonResponse({}) as any; - }); - vi.stubGlobal('fetch', fetchMock); - - try { - const schema: any = { - type: 'gantt', - gantt: { titleField: 'name', startDateField: 'start_date', endDateField: 'end_date' }, - // Pure API view — no objectName, no context dataSource prop. - data: { - provider: 'api', - read: { url: 'https://api.test/tasks' }, - write: { url: 'https://api.test/tasks', method: 'PATCH' }, - }, - }; - // NOTE: no `dataSource` prop — proves the api provider stands alone. - render(); - - // Read: rows come from the GET on the read url. - await waitFor(() => expect(screen.getAllByTestId('gantt-task')).toHaveLength(2)); - expect(fetchMock).toHaveBeenCalled(); - expect(String(fetchMock.mock.calls[0][0])).toContain('https://api.test/tasks'); - - // Write-back: dragging task 1 issues a PATCH to the write url with the - // rescheduled dates in the body. - fetchMock.mockClear(); - fireEvent.click(screen.getByTestId('gv-update-1')); - - await waitFor(() => { - const patch = fetchMock.mock.calls.find((c) => (c[1] as any)?.method === 'PATCH'); - expect(patch).toBeTruthy(); - }); - const patchCall = fetchMock.mock.calls.find((c) => (c[1] as any)?.method === 'PATCH')!; - expect(String(patchCall[0])).toContain('https://api.test/tasks/1'); - // No object schema on the api provider: the stored `2024-01-01` shape - // says the field is date-only, so the write is the calendar day - // (objectui#10866). This case used to expect the `toISOString()` - // spelling, i.e. it pinned the defect. - expect(JSON.parse((patchCall[1] as any).body)).toMatchObject({ - start_date: '2024-02-01', - end_date: '2024-02-05', - }); - } finally { - vi.unstubAllGlobals(); - } - }); + // --- No API provider (objectui#12325) ---------------------------------------- + // A case here used to pin that `data: {provider:'api', read, write}` read + // over fetch and persisted a drag through the write config. The view-level + // `api` data source is retired in @objectstack/spec (objectstack#23082), and + // ObjectGantt builds no adapter from it: the new meaning is pinned in + // `ObjectGantt.retiredApiProvider-12325.test.tsx`. // --- Dependency edit writeback (依赖增删 + 类型选择) ----------------------- const depData = [ diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index 2614eb0dd3..ffa6f0b37e 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -19,10 +19,10 @@ * - Task dependencies visualization * - Date range display * - Auto-scrolling timeline - * - Works with object/api/value data providers + * - Works with object/value data providers */ -import React, { useContext, useEffect, useState, useMemo, useCallback, useRef } from 'react'; +import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { toast } from 'sonner'; import type { ObjectGanttSchema, DataSource, GanttConfig } from '@object-ui/types'; import { GanttConfigSchema } from '@objectstack/spec/ui'; @@ -35,7 +35,6 @@ import { resolveFieldScale } from '@objectstack/spec/data'; import { useNavigationOverlay, useSettledSchema, - SchemaRendererContext, NonGridRowCeilingNote, useDataInvalidation, useFilterScope, @@ -448,7 +447,7 @@ function readTaskEnd(raw: unknown, chartZone: ChartZone): Date { * midnight names the PREVIOUS day everywhere east of UTC. * Any other declared type (`datetime`) keeps its instant, exactly as before. * - * With no declared type to ask (an `api` provider has no object schema), the + * With no declared type to ask (an inline `value` provider has no object schema), the * stored value's own shape answers: the same split {@link readTaskDate} and * {@link readTaskEnd} made when they read the value, so a write never * disagrees with the read. @@ -752,8 +751,8 @@ export const ObjectGantt: React.FC = ({ * `effectiveDataSource` like every other provider. That memo intentionally * keeps `dataConfig` as a dependency (not just its `object`/`items` * primitives), because `resolveDataSource` reads a provider-shaped slice of - * it — the whole `read`/`write` request config on `api` — which cannot be - * flattened to a fixed primitive list. Authored items therefore still reach + * it, which cannot be flattened to a fixed primitive list. Authored items + * therefore still reach * `reload`: new items → new `dataConfig` (deep-compared above) → new * adapter → new `reload`. The primitive is redundant, and a binding * documented as "the field `reload` reads" that `reload` does not read is @@ -762,19 +761,16 @@ export const ObjectGantt: React.FC = ({ // Resolve the ViewData config into a concrete DataSource adapter: // provider: 'object' → the context DataSource passed via props (unchanged) - // provider: 'api' → an ApiDataSource that executes the read/write HttpRequest config // provider: 'value' → an in-memory ValueDataSource - // Every read AND write-back below goes through this single adapter, so the - // 'api' provider now supports reschedule / dependency / delete / inline-edit - // write-backs — not just object-backed views. - // Host-authenticated fetch (SchemaRendererContext.apiFetch) so the 'api' - // provider's custom endpoints carry the same Authorization/tenant headers - // as native requests instead of relying on cookies alone (#2725). - const apiFetch = useContext(SchemaRendererContext)?.apiFetch; + // Every read AND write-back below goes through this single adapter. + // There is no `api` arm: the view-level `api` data source is retired in + // `@objectstack/spec` (objectstack#23082), so a block that still names it is + // an unknown provider to `resolveDataSource` and no request is built from + // its `read` / `write` (objectui#12325). const effectiveDataSource = useMemo( - () => resolveDataSource(dataConfig, dataSource ?? null, { fetch: apiFetch }), + () => resolveDataSource(dataConfig, dataSource ?? null), // dataConfig is already memoized by deep value above. - [dataConfig, dataSource, apiFetch], + [dataConfig, dataSource], ); /** * The adapter, reachable from the fetch path WITHOUT that path depending on @@ -785,8 +781,8 @@ export const ObjectGantt: React.FC = ({ * recompute even when the dependency list compares equal, and `useCallback` * is the same hint over a function. The memo above is not value-stable * under that — `resolveDataSource` returns the context adapter UNCHANGED for - * `provider: 'object'`, but CONSTRUCTS a new `ApiDataSource` / - * `ValueDataSource` for `'api'` and `'value'`. So on those two providers a + * `provider: 'object'`, but CONSTRUCTS a new `ValueDataSource` for + * `'value'`. So on that provider a * discard alone handed `reload` a brand-new adapter for a byte-identical * authored config, rebuilt `reload`, and re-fired the mount effect below — * one redundant round trip, per discard, on a component nobody had touched. @@ -795,7 +791,7 @@ export const ObjectGantt: React.FC = ({ * there and this one stayed on it. * * ⇒ `reload` READS the adapter through this ref and KEYS on the values that - * determine it: `adapterInputsKey` below, plus `dataSource` and `apiFetch` — + * determine it: `adapterInputsKey` below, plus `dataSource` — * this memo's own dependency list, with the memoised `dataConfig` replaced by * the VALUES `resolveDataSource` reads out of it. A genuine change to any of * them still rebuilds `reload` and still refetches; a discard alone no longer @@ -810,9 +806,8 @@ export const ObjectGantt: React.FC = ({ * * ⛔ Deliberately NOT `dataConfigKey`, the whole config's deep value. The * note above `dataItems` says such a flattening "cannot be flattened to a - * fixed primitive list" — true of a fixed LIST, and the reason that note - * gives is exactly right: the `api` arm's input is a whole `read`/`write` - * request config. A per-arm VALUE, however, is expressible, and it has to be + * fixed primitive list" — true of a fixed LIST. A per-arm VALUE, however, + * is expressible, and it has to be * the per-arm one rather than the whole config, because the whole config is * WIDER than the adapter's inputs. `ObjectGantt.discardedConfigMemo.test.tsx` * measures the difference: it moves an inert field inside `schema.data` that @@ -821,24 +816,21 @@ export const ObjectGantt: React.FC = ({ * the `object` provider, where the adapter is the context DataSource and the * config contributes nothing to it at all. * - * ⚠️ The arms below mirror `resolveDataSource` (`@object-ui/core`) — `read` + - * `write` for `'api'`, `items` for `'value'`, nothing but the fallback for - * `'object'` and for an unknown provider. Everything else the record query - * derives from the config reaches the list through its own binding + * ⚠️ The arms below mirror `resolveDataSource` (`@object-ui/core`) — `items` + * for `'value'`, nothing but the fallback for `'object'` and for an unknown + * provider (the retired `'api'` included, objectui#12325). Everything else + * the record query derives from the config reaches the list through its own binding * (`resource`, `dataProvider`, `hasInlineData`), so this key answers for the * ADAPTER and only the adapter. */ const adapterInputsKey = JSON.stringify( - rawDataConfig?.provider === 'api' - ? { provider: 'api', read: rawDataConfig.read, write: rawDataConfig.write } - : rawDataConfig?.provider === 'value' - ? { provider: 'value', items: rawDataConfig.items } - : { provider: rawDataConfig?.provider ?? null }, + rawDataConfig?.provider === 'value' + ? { provider: 'value', items: rawDataConfig.items } + : { provider: rawDataConfig?.provider ?? null }, ); - // Unified resource name for find/update/delete. For 'object' it's the bound - // object; for 'api' the adapter ignores it (the URL carries the endpoint), - // so an empty string is fine there. + // Unified resource name for find/update/delete: the bound object for + // 'object', `schema.objectName` otherwise. const resource = resolveRecordSourceObjectName(schema, dataConfig) ?? ''; /** @@ -1033,9 +1025,9 @@ export const ObjectGantt: React.FC = ({ throw new Error('DataSource required for object/api providers'); } - // 'object' → context adapter, 'api' → ApiDataSource (both resolved above). + // 'object' → the context adapter (resolved above). // Auto-inject $expand for lookup/master_detail fields when a schema is - // available; api adapters return an empty field map, so expand stays off. + // available. // // [objectui#7230] FIELD-LEVEL SECURITY ON `$expand`, the gate // objectui#7215 / PR #7229 put on the two projection sites in its scope. @@ -1149,7 +1141,7 @@ export const ObjectGantt: React.FC = ({ // `ObjectGantt.discardedReloadIdentity-10036.test.tsx` hold the two in // parity by exercising each entry. // eslint-disable-next-line react-hooks/exhaustive-deps -- `searchFields` is keyed by its serialised value, `searchFieldsKey`; the host rows are read through `hostRowsRef` (objectui#7333) - }, [adapterInputsKey, dataSource, apiFetch, resource, hasInlineData, dataProvider, queryFilter, schema.sort, searchTerm, searchFieldsKey, objectSchema, perms]); + }, [adapterInputsKey, dataSource, resource, hasInlineData, dataProvider, queryFilter, schema.sort, searchTerm, searchFieldsKey, objectSchema, perms]); /** * Does the query this effect is about to issue DERIVE anything from the @@ -1212,7 +1204,7 @@ export const ObjectGantt: React.FC = ({ useEffect(() => { if (recordQueryDerivesExpand && !objectSchemaReady) return; reloadRef.current(loadedOnceRef.current ? { inPlace: true } : {}); - }, [adapterInputsKey, dataSource, apiFetch, resource, hasInlineData, dataProvider, queryFilter, schema.sort, searchTerm, searchFieldsKey, objectSchema, perms, recordQueryDerivesExpand, objectSchemaReady]); + }, [adapterInputsKey, dataSource, resource, hasInlineData, dataProvider, queryFilter, schema.sort, searchTerm, searchFieldsKey, objectSchema, perms, recordQueryDerivesExpand, objectSchemaReady]); /** * objectui#10035 — the refresh input this gantt had none of, so a host could @@ -1264,7 +1256,7 @@ export const ObjectGantt: React.FC = ({ const resolveBorderColorFieldValue = createFieldColorResolver(fieldDefs[borderColorField ?? '']); // Fallback value→label maps from the view's quickFilters config. When the - // data comes from an `api` provider there is no object schema, so select + // data comes from an inline `value` provider there is no object schema, so select // fields have no option defs — but the same view often declares the exact // label pairs as quick-filter options (e.g. status: completed → "Completed"). // Reuse them so the tooltip shows display labels, not raw machine values. @@ -1338,7 +1330,7 @@ export const ObjectGantt: React.FC = ({ const opt = options.find((o) => String(o.value) === String(value)); if (opt) return opt.label; } - // No schema options (api provider has no object schema) → quick-filter + // No schema options (an inline `value` provider has no object schema) → quick-filter // options declared for the same field carry the display labels. if (typeof value !== 'object') { const qfLabel = quickFilterLabels.get(fieldName)?.get(String(value)); @@ -2045,11 +2037,10 @@ export const ObjectGantt: React.FC = ({ // `GanttView` hands `onTaskClick` a task and not a DOM event. const { anchorRef, anchorCaptureProps } = useOverlayAnchor(); - // #2473: an `api`-provider row is a composed render payload (bar_color, - // node_type, sort_key…), not the business record — and a foreign-object row - // has no schema at all, so the drawer degraded to humanized English labels. - // When the drawer opens, fetch the REAL record (and its schema for foreign - // objects) through the context DataSource; fall back to the raw row when + // #2473: a foreign-object row (its `objectField` names another object than + // the bound one) has no schema loaded, so the drawer degraded to humanized + // English labels. When the drawer opens, fetch the REAL record (and its + // schema) through the context DataSource; fall back to the raw row when // there's no context DS or the fetch fails (inline `value` demos keep // working unchanged). const [drawerFetch, setDrawerFetch] = useState<{ key: string; record: any; schema: any } | null>(null); @@ -2061,7 +2052,7 @@ export const ObjectGantt: React.FC = ({ const detail = recordDetailHref(drawerRec); if (!detail) { setDrawerFetch(null); return; } const { objectName, recordId } = detail; - const needsRealRecord = dataConfig?.provider === 'api' || objectName !== resource; + const needsRealRecord = objectName !== resource; if (!needsRealRecord || !dataSource || typeof dataSource.findOne !== 'function') { setDrawerFetch(null); return; @@ -2071,9 +2062,8 @@ export const ObjectGantt: React.FC = ({ (async () => { try { // Schema comes from the SAME context DataSource as the record — the - // component-level `objectSchema` state is unusable here: under the - // 'api' provider it's the adapter's `{fields: {}}` stub, which blanks - // every field label in the drawer. + // component-level `objectSchema` state is the BOUND object's, not + // this row's. const [record, schema] = await Promise.all([ dataSource.findOne(objectName, String(recordId)), typeof dataSource.getObjectSchema === 'function' @@ -2087,7 +2077,7 @@ export const ObjectGantt: React.FC = ({ } })(); return () => { cancelled = true; }; - }, [drawerRec, recordDetailHref, dataConfig?.provider, resource, dataSource]); + }, [drawerRec, recordDetailHref, resource, dataSource]); // Persist a drag-driven reschedule back to the data source. Mirrors // ObjectCalendar.handleEventDropDefault: optimistic local patch, then @@ -2579,10 +2569,10 @@ export const ObjectGantt: React.FC = ({ } inlineEdit onRefresh={ - // Only meaningful when there's a live source to re-read (object or - // api provider); inline `value` items and the data prop are owned - // by the host. - (dataConfig?.provider === 'object' || dataConfig?.provider === 'api') && + // Only meaningful when there's a live source to re-read (the + // object provider); inline `value` items and the data prop are + // owned by the host. + dataConfig?.provider === 'object' && typeof effectiveDataSource?.find === 'function' ? () => reload({ silent: true }) : undefined diff --git a/packages/plugin-gantt/src/__tests__/recordSourceInputs-10394.test.tsx b/packages/plugin-gantt/src/__tests__/recordSourceInputs-10394.test.tsx index bd4fe5e139..a241f1c668 100644 --- a/packages/plugin-gantt/src/__tests__/recordSourceInputs-10394.test.tsx +++ b/packages/plugin-gantt/src/__tests__/recordSourceInputs-10394.test.tsx @@ -35,8 +35,9 @@ * make, measured rather than assumed: * 6. `data` wins over `staticData` and `objectName`; `staticData` wins over * `objectName`; neither inline source queries the object. - * 7. An `object` provider queries its own object, not `objectName`; an `api` - * provider reads through its request. + * 7. An `object` provider queries its own object, not `objectName`; a retired + * `api` block (objectstack#23082, objectui#12325) requests nothing and is + * answered like an unknown provider: `objectName` is queried. * 8. A bare array under `data` is not a record source: `staticData` beside it * is what is charted. * @@ -229,21 +230,17 @@ describe('objectui#10394 — what the declared descriptions claim, through Schem expect(queried).toEqual(['task']); }); - it('an api provider reads through its request', async () => { + it('a retired api block requests nothing and is answered like an unknown provider', async () => { fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation( - async () => - new Response( - JSON.stringify({ data: [{ id: 'a1', subject: 'A1', visible_from: '2026-01-01', due_date: '2026-01-02' }] }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), + async () => new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }), ); const { ids, queried } = await chart( { data: { provider: 'api', read: { url: 'https://api.test/tasks' } }, objectName: 'account' }, - 'a1', + 'f1', ); - expect(ids).toBe('a1'); - expect(queried).toEqual([]); - expect(fetchSpy.mock.calls.map((call: unknown[]) => String(call[0]).split('?')[0])).toContain('https://api.test/tasks'); + expect(ids).toBe('f1'); + expect(queried).toEqual(['account']); + expect(fetchSpy.mock.calls.map((call: unknown[]) => String(call[0]).split('?')[0])).not.toContain('https://api.test/tasks'); }); it('a bare array under data is not a record source', async () => { diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 865a006a14..d9d1945607 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -1596,17 +1596,6 @@ export const ListView = React.forwardRef(({ ) { return false; } - // Renderer-owned data (gantt + api provider): ListView never fetches, - // so don't flash its skeleton either. - if ( - schema.viewType === 'gantt' && - schema.data && - typeof schema.data === 'object' && - !Array.isArray(schema.data) && - (schema.data as any).provider === 'api' - ) { - return false; - } return true; }); const [objectDef, setObjectDef] = React.useState(null); @@ -2346,41 +2335,6 @@ export const ListView = React.forwardRef(({ schema.objectName, ]); - // A gantt view whose `data` names the api provider is fed by a composite - // endpoint that ObjectGantt resolves itself (resolveDataSource → - // ApiDataSource, reads AND write-backs). ListView must neither fetch - // schema.objectName rows for it nor hand its rows `data` prop down. - // - // ⛔ What the second half does NOT do (objectui#7222). It does not - // "short-circuit the renderer's own fetch" — an earlier version of this - // comment said it did, and believing that is exactly what made objectui#7210's - // double fetch invisible on a read-through. No host prop reaches the chart at - // all: the registered `object-gantt` renderer (`plugin-gantt/src/index.tsx`) - // destructures `({ schema })` and hands `ObjectGantt` exactly `schema` and - // `dataSource`, so `data`, `onRowClick`, `rowHeight` and the rest of - // `baseProps` are dropped one layer up, and the chart queries for itself - // whichever branch the render below takes. It is the one view wrapper that - // forwards nothing — `object-grid`, `object-kanban`, `object-calendar`, - // `object-map` and `object-tree` all spread `{...props}`. Pinned - // behaviourally, one package over, in - // `plugin-gantt/src/ObjectGantt.hostDataProp-7210.test.tsx`. - // - // The withholding is kept anyway: it is unreachable, not wrong, and it stops - // being unreachable the moment that wrapper forwards host props — whether a - // non-grid view may fetch unbounded at all is an open maintainer decision - // (objectui#7210, half 2). This view's rows array is not the endpoint's - // answer (the fetch effect below returns early for it), and `ObjectGantt` - // adopts any NON-EMPTY host `data` array as its rows. An EMPTY one no longer - // blanks the chart: it reads as "no host rows yet" and the chart still - // queries its endpoint (objectui#7333). So the withholding guards against - // this array ever being adopted in the endpoint's place. - const ganttOwnsData = - currentView === 'gantt' && - !!schema.data && - typeof schema.data === 'object' && - !Array.isArray(schema.data) && - (schema.data as any).provider === 'api'; - /** * Does a GROUPED grid query for itself? (objectui#7189, maintainer ruling A) * @@ -2494,10 +2448,10 @@ export const ListView = React.forwardRef(({ // below names it, so the rows are re-read in place (`RefreshIndicator` over // the current rows, no remount). A page action over raw HTTP fires no // `onMutation`, so the subscription above cannot see it; the bus can. - // Subscribed only when the list fetches for itself — inline rows and a - // gantt that owns its endpoint are not this effect's query. + // Subscribed only when the list fetches for itself — inline rows are not + // this effect's query. const listFetchesForItself = - !Array.isArray(schema.data) && (schema.data as any)?.provider !== 'value' && !ganttOwnsData; + !Array.isArray(schema.data) && (schema.data as any)?.provider !== 'value'; const invalidationNonce = useDataInvalidation(listFetchesForItself ? schema.objectName || undefined : undefined); /** @@ -2648,14 +2602,6 @@ export const ListView = React.forwardRef(({ return; } - // Renderer-owned data (gantt + api provider): the view component fetches - // from its endpoint itself; just clear the loading state. - if (ganttOwnsData) { - setLoading(false); - setDataLimitReached(false); - return; - } - // [objectui#10881] A grouped grid this data source cannot count is refused // before it is mounted (`groupingNeedsHeaderQuery`), so no window is // fetched for it, and none held: the rows of an earlier query would @@ -3204,9 +3150,8 @@ export const ListView = React.forwardRef(({ // Naming `currentView` therefore re-issued an identical `find` on every // switch — measured in a browser as two byte-identical // `GET /api/v1/data/showcase_task?top=100&select=…` round trips for one - // board. `ganttOwnsData` and `groupingConfig` stay named: the first flips - // this effect between fetching and standing down, and the second changes - // the projection it asks for, so both move the request itself. + // board. `groupingConfig` stays named: it changes the projection the + // effect asks for, so it moves the request itself. // // ⭐ TWO names left this list, not one: `serverPage` went with // `currentView`, and that is the SAME removal rather than a second, @@ -3228,9 +3173,8 @@ export const ListView = React.forwardRef(({ // the user leaves it for a board — a second identical request under the old // list. Read it as a claim about this definition, ⛔ not as a measured one. // - // objectui#10881 — `groupingNeedsHeaderQuery` is named for the reason - // `ganttOwnsData` is: it flips this effect between fetching and standing - // down. It reads `currentView`, so leaving a refused grouped grid for a + // objectui#10881 — `groupingNeedsHeaderQuery` is named because it flips + // this effect between fetching and standing down. It reads `currentView`, so leaving a refused grouped grid for a // board re-runs this effect once, and that run is the board's fetch. // // objectui#10689 — `predicateProjectionKey` is the harvested predicate @@ -3248,7 +3192,7 @@ export const ListView = React.forwardRef(({ // silently un-suppresses nothing, because the finding it was suppressing // simply moves elsewhere. Add prose ABOVE this point, never below it. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [schema.objectName, schema.data, dataSource, authoredFilter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, schema.map, (schema as any).options, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, predicateProjectionKey, ganttOwnsData, invalidationNonce, groupingNeedsHeaderQuery, calendarWindowKey, rowsCarryConcurrencyToken]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change; `rowsCarryConcurrencyToken` (objectui#12292) re-reads the rows with their token when inline edit turns on + }, [schema.objectName, schema.data, dataSource, authoredFilter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, schema.map, (schema as any).options, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, predicateProjectionKey, invalidationNonce, groupingNeedsHeaderQuery, calendarWindowKey, rowsCarryConcurrencyToken]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change; `rowsCarryConcurrencyToken` (objectui#12292) re-reads the rows with their token when inline edit turns on // Any change to the result-defining inputs (object, filters, sort, search, // grouping, page size) invalidates the current page number — snap back to @@ -3604,7 +3548,8 @@ export const ListView = React.forwardRef(({ * this component's fetch and nothing the user could see: * * - `gantt`: the registered `object-gantt` renderer forwards no host prop - * (see `ganttOwnsData` above) and `ObjectGantt.reload` hands + * (`plugin-gantt/src/ObjectGantt.hostDataProp-7210.test.tsx` pins that) + * and `ObjectGantt.reload` hands * `schema.filter` to `$filter`; * - `tree`: `ObjectTree`'s object-provider branch runs its own `find` with * `$filter: schema.filter`, and it runs BEFORE its host-`data` branch; @@ -4085,8 +4030,8 @@ export const ListView = React.forwardRef(({ // ⛔ This is route A only. Forwarding host PROPS to the chart is // objectui#7210 half 2's scope and collides with objectui#7333. ...(schema.navigation ? { navigation: schema.navigation } : {}), - // ViewData pass-through: a view authored with `data: {provider:'api', - // read, write}` (composite endpoint) must reach ObjectGantt, whose + // ViewData pass-through: a view authored with a `data` provider + // block (`object` / `value`) must reach ObjectGantt, whose // getDataConfig prefers schema.data over the objectName fallback. ...(schema.data ? { data: schema.data } : {}), ...(startDateField ? { startDateField } : {}), @@ -5731,12 +5676,10 @@ export const ListView = React.forwardRef(({ { }); }); -describe('ListView — gantt view fed by an api-provider ViewData', () => { - // A gantt view authored with `data: {provider:'api', read, write}` is fed by - // a composite endpoint that ObjectGantt resolves itself (resolveDataSource → - // ApiDataSource). ListView must (a) forward schema.data into the gantt - // component schema, (b) NOT fetch schema.objectName rows itself, and (c) NOT - // pass its rows `data` prop down. +describe('ListView — gantt view whose ViewData is a retired api block', () => { + // This block used to pin that a gantt view authored with `data: {provider: + // 'api', read, write}` was "renderer-owned": ListView stood down (no fetch of + // schema.objectName, no rows prop) because ObjectGantt resolved an + // ApiDataSource for it. The view-level `api` data source is retired in + // @objectstack/spec (objectstack#23082) and ObjectGantt builds no adapter + // from it any more (objectui#12325), so ListView no longer stands down + // either: the block is just a non-`value` provider, and the view fetches + // its bound object and hands the rows down like any other list. // - // (c) is asserted against the props-recording STUB below, which is the only - // place it is observable at all: the real `object-gantt` renderer forwards no - // host prop but `schema`, so nothing ListView puts on this element reaches - // the chart today (objectui#7222; pinned one package over in - // `plugin-gantt/src/ObjectGantt.hostDataProp-7210.test.tsx`). That makes (c) - // a guard on a path the registry does not currently reach — keep it. The day - // that wrapper forwards host props (objectui#7210, half 2, an open maintainer - // decision) it becomes load-bearing: this view's rows array is not the - // endpoint's answer, and `ObjectGantt` adopts any NON-EMPTY host array as its - // rows. (An EMPTY one no longer blanks the chart: it reads as "no host rows - // yet" and the chart still queries its endpoint, objectui#7333.) + // The rows prop is asserted against the props-recording STUB below: the real + // `object-gantt` renderer forwards no host prop but `schema` (objectui#7222; + // pinned in `plugin-gantt/src/ObjectGantt.hostDataProp-7210.test.tsx`). let prevObjectGantt: ReturnType; let ganttCalls: any[]; @@ -2746,34 +2741,35 @@ describe('ListView — gantt view fed by an api-provider ViewData', () => { mockDataSource.find.mockClear(); }); - const apiData = { - provider: 'api' as const, - read: { url: '/api/gantt/tree', method: 'GET' as const }, - write: { url: '/api/gantt/task', method: 'PATCH' as const }, + const retiredApiData = { + provider: 'api', + read: { url: '/api/gantt/tree', method: 'GET' }, + write: { url: '/api/gantt/task', method: 'PATCH' }, }; - it('forwards schema.data to the gantt schema, skips its own fetch, and withholds the rows prop', async () => { + it('no longer stands down: it fetches the bound object and passes the rows down', async () => { + mockDataSource.find.mockResolvedValue([ + { id: '1', name: 'P1', start_date: '2026-01-01', end_date: '2026-01-02' }, + ]); const schema = { type: 'list-view', objectName: 'production_plan', viewType: 'gantt', fields: ['name'], - data: apiData, + data: retiredApiData, gantt: { startDateField: 'start_date', endDateField: 'end_date' }, } as unknown as ListViewSchema; renderWithProvider(); expect(await screen.findByTestId('gantt-stub')).toBeInTheDocument(); - // (a) the ViewData config reached the component schema - const last = ganttCalls.at(-1); - expect(last?.schema?.data).toEqual(apiData); - // (b) ListView did not query the bound object itself - expect(mockDataSource.find).not.toHaveBeenCalled(); - // (c) no rows array prop — the schema-spread `data` (the ViewData object) - // must be what arrives. Not observable through the real renderer today - // (see the note on this describe block); this pins ListView's own output. - expect(Array.isArray(last?.data)).toBe(false); + await vi.waitFor(() => { + const last = ganttCalls.at(-1); + expect(Array.isArray(last?.data)).toBe(true); + expect(last?.data).toHaveLength(1); + }); + expect(mockDataSource.find).toHaveBeenCalled(); + expect(mockDataSource.find.mock.calls[0][0]).toBe('production_plan'); }); it('keeps the legacy object-provider path: ListView fetches and passes rows down', async () => { diff --git a/packages/plugin-map/src/ObjectMap.tsx b/packages/plugin-map/src/ObjectMap.tsx index 61631bb33d..1947b13116 100644 --- a/packages/plugin-map/src/ObjectMap.tsx +++ b/packages/plugin-map/src/ObjectMap.tsx @@ -800,7 +800,7 @@ export const ObjectMap: React.FC = ({ const dataProvider = dataConfig?.provider; // NOT a delegation site for `resolveRecordSourceObjectName` (`b041b9c0c`): // this is the data config's OWN object, deliberately `undefined` for every - // other provider so an `api`/`value` map's `objectName` changing cannot move + // other provider so a `value` map's `objectName` changing cannot move // this dependency. The shared reader's second rung would put `objectName` // here and re-run both effects on a value they do not read. const dataObjectName = dataConfig?.provider === 'object' ? dataConfig.object : undefined; @@ -979,8 +979,7 @@ export const ObjectMap: React.FC = ({ // inline set has no metadata to build. Falling through here would // therefore throw `DataSource required for object/api providers` on a // map that needs no DataSource at all. So the adapter is resolved for - // the inline provider ONLY — `api` keeps exactly the behaviour it had - // — and the same three keys are lowered onto the same query shape. + // the inline provider ONLY, and the same three keys are lowered onto the same query shape. // // Built here rather than memoised at render scope so this effect goes // on reading only the primitive fields objectui#6592 named @@ -1087,10 +1086,10 @@ export const ObjectMap: React.FC = ({ const capped = applyNonGridRowCeiling(result); commit(capped.rows, capped); - } else if (dataProvider === 'api') { - console.warn('API provider not yet implemented for ObjectMap'); - commit([], null); } + // No `api` arm: the view-level `api` data source is retired in + // `@objectstack/spec` (objectstack#23082) and read by nothing here + // (objectui#12325). } catch (err) { if (silent) { // A failed re-read of the query on screen keeps the last good rows diff --git a/packages/plugin-map/src/index.recordSourceInputs-10394.test.tsx b/packages/plugin-map/src/index.recordSourceInputs-10394.test.tsx index d8ed999c72..5baba85bfc 100644 --- a/packages/plugin-map/src/index.recordSourceInputs-10394.test.tsx +++ b/packages/plugin-map/src/index.recordSourceInputs-10394.test.tsx @@ -231,7 +231,11 @@ describe('objectui#10394 — what the declared descriptions claim, through Schem expect(lats).toEqual(latsOf(STATIC_ROWS)); }); - it.each(MAP_KEYS)('$label — the api provider plots no markers and still shadows staticData', async ({ type }) => { + // objectui#12325 — the view-level `api` data source is retired in the spec + // (objectstack#23082). The map's `api` arm, which only warned "not yet + // implemented", is deleted: a block that still says `api` plots nothing, + // queries nothing, still shadows `staticData`, and no arm answers for it. + it.each(MAP_KEYS)('$label — a retired api block plots no markers, still shadows staticData, and has no arm', async ({ type }) => { warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const { lats, queried } = await plot(type, { data: { provider: 'api', read: { url: '/api/stores' } }, @@ -239,7 +243,7 @@ describe('objectui#10394 — what the declared descriptions claim, through Schem }); expect(lats).toEqual([]); expect(queried).toEqual([]); - expect(warn.mock.calls.map((call: unknown[]) => String(call[0]))).toContain( + expect(warn.mock.calls.map((call: unknown[]) => String(call[0]))).not.toContain( 'API provider not yet implemented for ObjectMap', ); }); diff --git a/packages/react/src/__tests__/useViewData.test.tsx b/packages/react/src/__tests__/useViewData.test.tsx index 8f68fa285e..1e9d463d3f 100644 --- a/packages/react/src/__tests__/useViewData.test.tsx +++ b/packages/react/src/__tests__/useViewData.test.tsx @@ -422,66 +422,55 @@ describe('useViewData — edge cases', () => { }); // --------------------------------------------------------------------------- -// provider: 'api' — host-authenticated fetch (#2725) +// provider: 'api' — retired (objectstack#23082, objectui#12325) // --------------------------------------------------------------------------- - -describe("useViewData — provider: 'api' host auth fetch", () => { - const apiViewData: ViewData = { +// This block used to pin that an `api` view block fetched its `read.url` +// through the context `apiFetch`. The view-level `api` data source is retired +// in the spec, and `resolveDataSource` has no arm for it, so the block is an +// unknown provider: it falls back to the context DataSource, and its URL is +// requested through no channel at all. + +describe("useViewData — a retired provider: 'api' block", () => { + const retiredViewData = { provider: 'api', read: { url: '/api/gantt/tree', method: 'GET' }, - } as ViewData; - - function jsonResponse() { - return new Response(JSON.stringify({ data: [{ id: '1' }], total: 1 }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } - - function apiFetchWrapper(apiFetch: typeof fetch) { - return ({ children }: { children: React.ReactNode }) => - React.createElement( - SchemaRendererContext.Provider, - { value: { dataSource: null, apiFetch } }, - children, + } as unknown as ViewData; + + it('requests its URL through neither the context apiFetch nor the global fetch', async () => { + const apiFetch = vi.fn(async () => new Response('{}', { status: 200 })) as unknown as typeof fetch; + const bareFetch = vi.fn(async () => new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', bareFetch); + const contextDS = { + find: vi.fn().mockResolvedValue({ data: [{ id: 'ctx-1' }], total: 1 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + } as unknown as DataSource; + + try { + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement( + SchemaRendererContext.Provider, + { value: { dataSource: contextDS, apiFetch } }, + children, + ); + const { result } = renderHook( + () => useViewData({ viewData: retiredViewData, resource: 'tasks' }), + { wrapper }, ); - } - - it('routes api-provider requests through the context apiFetch', async () => { - const apiFetch = vi.fn(async () => jsonResponse()) as unknown as typeof fetch; - - const { result } = renderHook( - () => useViewData({ viewData: apiViewData, resource: 'tasks' }), - { wrapper: apiFetchWrapper(apiFetch) }, - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(apiFetch).toHaveBeenCalled(); - expect(result.current.data).toEqual([{ id: '1' }]); - }); - - it('lets an explicit adapterOptions.fetch win over the context apiFetch', async () => { - const contextFetch = vi.fn(async () => jsonResponse()) as unknown as typeof fetch; - const explicitFetch = vi.fn(async () => jsonResponse()) as unknown as typeof fetch; - - const { result } = renderHook( - () => - useViewData({ - viewData: apiViewData, - resource: 'tasks', - adapterOptions: { fetch: explicitFetch }, - }), - { wrapper: apiFetchWrapper(contextFetch) }, - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - expect(explicitFetch).toHaveBeenCalled(); - expect(contextFetch).not.toHaveBeenCalled(); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // The unknown-provider answer: the context DataSource, read for `resource`. + expect(result.current.dataSource).toBe(contextDS); + expect(contextDS.find).toHaveBeenCalledWith('tasks', undefined); + expect(apiFetch).not.toHaveBeenCalled(); + expect(bareFetch).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } }); }); diff --git a/packages/react/src/hooks/useViewData.ts b/packages/react/src/hooks/useViewData.ts index a9797c9cc6..45f5e749aa 100644 --- a/packages/react/src/hooks/useViewData.ts +++ b/packages/react/src/hooks/useViewData.ts @@ -69,8 +69,10 @@ export interface UseViewDataResult { * * 1. Resolves the ViewData config to the right DataSource adapter * - `provider: 'object'` → context DataSource (ObjectStackAdapter) - * - `provider: 'api'` → ApiDataSource (raw HTTP) * - `provider: 'value'` → ValueDataSource (in-memory) + * The view-level `provider: 'api'` is retired in `@objectstack/spec` + * (objectstack#23082) and has no arm in `resolveDataSource`: a block that + * still names it is an unknown provider there (objectui#12325). * 2. Fetches data automatically on mount * 3. Provides loading/error states and refresh capability * @@ -99,30 +101,19 @@ export function useViewData(options: UseViewDataOptions): UseViewDat skip = false, } = options; - // Get context DataSource + host-authenticated fetch + // Get context DataSource const context = useContext(SchemaRendererContext); const contextDataSource = context?.dataSource ?? null; - const contextApiFetch = context?.apiFetch; // Resolve the DataSource — memoize to prevent re-creation on every render. // We key on the viewData provider + config identity. const resolvedDataSource = useMemo(() => { if (explicitDataSource) return explicitDataSource; - // Default the 'api' provider's fetch to the host's authenticated fetch - // (SchemaRendererContext.apiFetch) so custom endpoints carry the same - // credentials as native requests; explicit adapterOptions still win. - return resolveDataSource(viewData, contextDataSource, { - fetch: contextApiFetch, - ...adapterOptions, - }); + return resolveDataSource(viewData, contextDataSource, adapterOptions); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ explicitDataSource, - contextApiFetch, viewData?.provider, - // For 'api' provider, key on read/write URLs - viewData?.provider === 'api' ? (viewData as any).read?.url : undefined, - viewData?.provider === 'api' ? (viewData as any).write?.url : undefined, // For 'value' provider, key on items reference (or length as proxy) viewData?.provider === 'value' ? (viewData as any).items?.length : undefined, contextDataSource, diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 203b232a19..804fd08dc8 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -100,7 +100,7 @@ export type { ObjectCalendarBlockConfig } from './zod/objectql.zod.js'; export type { HttpMethodSubset as HttpMethod } from '@objectstack/spec/ui'; /** - * HTTP Request Configuration for API Provider + * HTTP Request Configuration (the shape `ApiDataSource` is constructed with). * Canonical definition from @objectstack/spec/ui. */ export type { HttpRequest } from '@objectstack/spec/ui'; @@ -109,10 +109,13 @@ export type { HttpRequest } from '@objectstack/spec/ui'; * View Data Source Configuration * Canonical definition from @objectstack/spec/ui. * - * Supports three modes: + * The modes a view binds: * 1. 'object': Standard Protocol - Auto-connects to ObjectStack Metadata and Data APIs - * 2. 'api': Custom API - Explicitly provided API URLs - * 3. 'value': Static Data - Hardcoded data array + * 2. 'value': Static Data - Hardcoded data array + * + * The view-level 'api' mode is retired in @objectstack/spec (objectstack#23082): + * a metadata-declared API call is the page element's `dataSource` binding, and + * nothing in objectui reads a view's 'api' block (objectui#12325). */ export type { ViewData } from '@objectstack/spec/ui'; From 93d43230c47ff34387d2fecc072f3e47bb7a3bbc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:11:56 +0000 Subject: [PATCH 2/2] test: move the default-strip pins off the retired api arm; lint fix (objectui#12325) The object-gantt / object-grid "parse authors no default" pins rode on the api arm's read.method default, which does not compile against @objectstack/spec main (TS2352). They now ride on navigation's defaults. Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- ...Gantt.discardedReloadIdentity-10036.test.tsx | 2 +- ...object-gantt-properties-bag-10859-b6.test.ts | 17 ++++++++++------- .../object-grid-properties-bag-11276.test.ts | 14 ++++++++++---- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/plugin-gantt/src/ObjectGantt.discardedReloadIdentity-10036.test.tsx b/packages/plugin-gantt/src/ObjectGantt.discardedReloadIdentity-10036.test.tsx index d8414110f7..c9dd91d673 100644 --- a/packages/plugin-gantt/src/ObjectGantt.discardedReloadIdentity-10036.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.discardedReloadIdentity-10036.test.tsx @@ -224,7 +224,7 @@ describe("ObjectGantt provider:'value' — the record read survives a discarded const tree = () => ; const restore = armDiscardProxy(); - let mounted = 0; + let mounted: number; try { const { rerender } = render(tree()); await waitFor(() => expect(log.length).toBeGreaterThan(0)); diff --git a/packages/types/src/__tests__/object-gantt-properties-bag-10859-b6.test.ts b/packages/types/src/__tests__/object-gantt-properties-bag-10859-b6.test.ts index 88784b5975..8f99a3d8ba 100644 --- a/packages/types/src/__tests__/object-gantt-properties-bag-10859-b6.test.ts +++ b/packages/types/src/__tests__/object-gantt-properties-bag-10859-b6.test.ts @@ -245,20 +245,23 @@ describe('object-gantt validates in the spec\'s `properties` bag (objectui#10859 expect(safeValidateSchema({ type: 'page', children: [OBJECT_BOUND, BOUND] }).success).toBe(true); }); - it('authors no default into the document: the row\'s `api` `method` default is stripped (objectui#8317)', () => { + // This pin used to ride on the `api` arm's `read.method` default. That arm + // is retired in the spec (objectstack#23082, objectui#12325), so it rides on + // `navigation`, whose spec row writes defaults too. + it('authors no default into the document: the row\'s `navigation` defaults are stripped (objectui#8317)', () => { const doc = { type: 'object-gantt', - properties: { data: { provider: 'api', read: { url: '/api/tasks' } }, gantt: GANTT }, + properties: { objectName: 'task', navigation: {}, gantt: GANTT }, } as const; - // Control: the spec's own row writes a default `method` into its output. - const specOut = SpecObjectGanttPropsSchema.parse(doc.properties) as { data: { read: Record } }; - expect(specOut.data.read.method).toBe('GET'); + // Control: the spec's own row writes a default `mode` into its output. + const specOut = SpecObjectGanttPropsSchema.parse(doc.properties) as unknown as { navigation: Record }; + expect(specOut.navigation.mode).toBe('page'); // This face validates and writes nothing. const result = safeValidateSchema(doc); expect(result.success).toBe(true); if (!result.success) return; - const out = result.data as unknown as { properties: { data: { read: Record } } }; - expect(Object.keys(out.properties.data.read)).toEqual(['url']); + const out = result.data as unknown as { properties: { navigation: Record } }; + expect(Object.keys(out.properties.navigation)).toEqual([]); }); }); diff --git a/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts b/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts index e9ac476b2c..d7aa185329 100644 --- a/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts +++ b/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts @@ -282,12 +282,18 @@ describe('object-grid validates in the spec\'s `properties` bag (objectui#11276) } }); - it('the parse authors no default into the document (objectui#8317): an `api` provider keeps no `method`', () => { - const doc = { type: 'object-grid', properties: { objectName: 'task', data: { provider: 'api', read: { url: '/tasks' } } } }; + // This pin used to ride on the `api` arm's `read.method` default. That arm + // is retired in the spec (objectstack#23082, objectui#12325), so it rides on + // `navigation`, whose spec row writes defaults too. + it('the parse authors no default into the document (objectui#8317): `navigation` keeps no `mode`', () => { + const doc = { type: 'object-grid', properties: { objectName: 'task', navigation: {} } }; + // Control: the spec's own row writes a default `mode` into its output. + const specOut = SpecObjectGridPropsSchema.parse(doc.properties) as unknown as { navigation: Record }; + expect(specOut.navigation.mode).toBe('page'); const parsed = safeValidateSchema(doc); expect(parsed.success).toBe(true); - const read = (parsed.data as { properties: { data: { read: Record } } }).properties.data.read; - expect('method' in read).toBe(false); + const navigation = (parsed.data as unknown as { properties: { navigation: Record } }).properties.navigation; + expect('mode' in navigation).toBe(false); }); });