diff --git a/.changeset/grid-select-published-fields.md b/.changeset/grid-select-published-fields.md new file mode 100644 index 0000000000..932e1c1a56 --- /dev/null +++ b/.changeset/grid-select-published-fields.md @@ -0,0 +1,17 @@ +--- +'@object-ui/app-shell': patch +--- + +Studio 的记录网格只请求服务端真有的列——「添加字段」不再把整个网格打成「该视图的查询被拒绝」 + +在 Data 支柱点一下「添加字段」,整片网格立刻变成错误态,并建议「清除筛选条件」——而现场根本没有筛选条件。 + +根因是投影的来源错了。`gridColumns` 取的是**草稿**对象的字段名,而这个数组是下游取数的输入;`addField` 只往本地草稿推一个 `field_`。于是列一变就重新取数,`select` 里带着服务端不存在的列,data API 按设计拒绝——它的错误信息还专门解释了为什么不能静默丢弃未知列:那会把窄投影悄悄答成宽投影。 + +修法的边界是**实测**出来的,不是猜的:把字段存成草稿返回 200 且 `state=draft`,紧接着的 `select` 指名它**仍然** 400。物化发生在**发布**时,所以「有没有保存」是错的问题,「服务端有没有」才是——答案在 `layered().effective` 这条基线里。 + +因此新增 `publishedFieldNames`(加载时取自基线),并让 `gridColumns` 只保留其中存在的列。新字段照常在右侧检查器里被选中和配置(那本来就是配置它的地方),发布之后它可查询了,才作为列出现在网格里。 + +过滤放在列数组这一处、而不是取数那一侧,是为了让「网格要什么」只有一个真相源。 + +回归测试 `DataPillar.gridProjection.test.tsx` 断言在**交给对象视图的列数组**上——那个数组就是投影本身;如果只监视取数调用,日后把数组在下游改一手也能过,缺陷就悄悄搬回来了。撤掉这一行过滤,第二条精确转红、第一条仍绿。 diff --git a/packages/app-shell/src/views/studio-design/DataPillar.gridProjection.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.gridProjection.test.tsx new file mode 100644 index 0000000000..fc26101682 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/DataPillar.gridProjection.test.tsx @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The grid asks only for columns the SERVER has — cloud#1652. + * + * "+ add field" appends `field_` to the object DRAFT. The grid's column + * array is a fetch input, so that append used to reach the data API as + * `select=…,field_11`, which the API refuses by design: dropping an unknown + * projection key would silently answer a NARROWER projection with a WIDER one. + * The refusal replaced the whole grid with 「该视图的查询被拒绝」 — on the most + * ordinary edit in the pillar, and with a message telling the operator to clear + * filters that were never there. + * + * Measured on a rig before writing this (the boundary the fix turns on): saving + * the field as a DRAFT returns 200 with `state=draft`, and the very next + * `select` naming it STILL answers 400. Materialisation happens at PUBLISH, so + * "has it been saved" is the wrong question for a projection — "does the server + * have it" is, and the baseline (`layered().effective`) is where that lives. + * + * The assertion is on the columns handed to the object view, because that array + * IS the projection. Asserting on a spy over the fetch would pass just as well + * if the array were fixed somewhere downstream — and then the next refactor + * would move the bug back without reddening anything. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +/** The object as the SERVER has it: two published, materialised fields. */ +const publishedObject = { + name: 'showcase_book', + label: 'Book', + fields: [ + { name: 'book_name', label: 'Title', type: 'text' }, + { name: 'author', label: 'Author', type: 'text' }, + ], +}; + +const mockClient = { + list: vi.fn(async () => [{ name: 'showcase_book', label: 'Book' }]), + listDrafts: vi.fn(async () => []), + layered: vi.fn(async () => ({ effective: publishedObject, code: publishedObject })), + getDraft: vi.fn(async () => null), + save: vi.fn(async () => ({ ok: true })), +}; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useMetadataClient: () => mockClient, + useMetadataTypes: () => ({ entries: [] }), + }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => []) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => ({}) }; +}); + +/** + * Capture the columns the pillar hands the object view — the projection itself. + * Rendered as text so an assertion reads the real array, not a mock's memory. + */ +const seenColumns: string[][] = []; +vi.mock('@object-ui/plugin-view', async (importOriginal) => { + const mod = await importOriginal>(); + return { + ...mod, + // The pillar imports this as `PluginObjectView`; the projection it renders + // with is `schema.table.fields`. + ObjectView: ({ schema }: { schema?: { table?: { fields?: string[] } } }) => { + const cols = schema?.table?.fields ?? []; + seenColumns.push(cols); + return
{cols.join(',')}
; + }, + }; +}); + +import { DataPillar } from './StudioDesignSurface'; +import { SurfaceDeepLinkProvider } from './surfaceDeepLinkChannel'; +import { registerBuiltinInspectors } from '../metadata-admin/inspectors'; + +registerBuiltinInspectors(); + +afterEach(() => { + seenColumns.length = 0; + cleanup(); +}); + +function renderPillar(packageId = 'com.example.showcase') { + return render( + + + + + , + ); +} + +const columnsNow = () => screen.getByTestId('grid-columns').textContent ?? ''; + +describe('DataPillar grid projection (cloud#1652)', () => { + it('opens on the published fields', async () => { + renderPillar(); + await waitFor(() => expect(columnsNow()).toContain('book_name')); + expect(columnsNow()).toContain('author'); + }); + + it('does NOT put a freshly added, unpublished field into the projection', async () => { + renderPillar(); + await waitFor(() => expect(columnsNow()).toContain('book_name')); + + const addField = screen.getByRole('button', { name: /添加|add field/i }); + fireEvent.click(addField); + + // The draft grew a `field_`; the projection must not have. + await waitFor(() => expect(columnsNow()).toContain('book_name')); + expect(columnsNow()).not.toMatch(/field_\d+/); + + // And not through any render along the way — a single frame that leaked the + // phantom column is one 400 and one blanked grid. + expect(seenColumns.some((cols) => cols.some((c) => /^field_\d+$/.test(c)))).toBe(false); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 70f199098b..77ca4ff317 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -2243,6 +2243,16 @@ export function DataPillar({ // A draft-only object has NO physical table yet (DDL lands at publish), so the // Records grid must not fire data SQL against it. const [hasBaseline, setHasBaseline] = React.useState(true); + /** + * The field names that EXIST on the server for the current object — i.e. the + * ones a data query may name in `select`. + * + * Measured, not assumed (cloud#1652): saving a field as a DRAFT returns 200 + * and `state=draft`, and the very next `select` naming it still answers + * `400 INVALID_FIELD`. Materialisation happens at PUBLISH, so the draft body + * is the wrong source for a projection even after a successful save. + */ + const [publishedFieldNames, setPublishedFieldNames] = React.useState>(new Set()); // The package's object-name namespace (framework#2694). New objects are // auto-prefixed with `_` so an author can never draft a prefix-less // object that publish would later reject (code NAMESPACE_PREFIX). @@ -2333,6 +2343,10 @@ export function DataPillar({ setObjDraft(draftBody ? { ...baseline, ...draftBody } : baseline); setHasDraft(!!draftBody); setHasBaseline(!!(lay.effective ?? lay.code)); + // The projection baseline: the object as the SERVER has it. `objDraft` + // below merges the draft on top, which is right for the editor and + // wrong for a `select`. + setPublishedFieldNames(new Set(readFields(baseline.fields).entries.map((e) => e.name))); } catch (e) { if (!cancelled) setError(formatMetadataError(e)); } finally { @@ -2378,8 +2392,20 @@ export function DataPillar({ () => readFields(objDraft.fields) .entries.map((e) => e.name) - .filter((n) => !STUDIO_SYSTEM_FIELD_NAMES.has(n) && n !== 'actions'), - [objDraft.fields], + .filter((n) => !STUDIO_SYSTEM_FIELD_NAMES.has(n) && n !== 'actions') + // cloud#1652 — a column the server does not have yet must not reach the + // `select`. "+ add field" appends `field_` to the DRAFT, this array + // is a fetch input, and the data API refuses an unknown projection key + // by design (dropping it would silently answer a NARROWER projection + // with a WIDER one). The result was that adding a field replaced the + // whole grid with "该视图的查询被拒绝" — on the most ordinary edit there is. + // + // Filtering here rather than at the fetch keeps ONE source of truth for + // what the grid asks for. The new field is still selected in the + // inspector, which is where it gets configured; it joins the grid once + // it is published and therefore queryable. + .filter((n) => publishedFieldNames.has(n)), + [objDraft.fields, publishedFieldNames], ); /**