Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/grid-select-published-fields.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
'@object-ui/app-shell': patch
---

Studio 的记录网格只请求服务端真有的列——「添加字段」不再把整个网格打成「该视图的查询被拒绝」

在 Data 支柱点一下「添加字段」,整片网格立刻变成错误态,并建议「清除筛选条件」——而现场根本没有筛选条件。

根因是投影的来源错了。`gridColumns` 取的是**草稿**对象的字段名,而这个数组是下游取数的输入;`addField` 只往本地草稿推一个 `field_<N>`。于是列一变就重新取数,`select` 里带着服务端不存在的列,data API 按设计拒绝——它的错误信息还专门解释了为什么不能静默丢弃未知列:那会把窄投影悄悄答成宽投影。

修法的边界是**实测**出来的,不是猜的:把字段存成草稿返回 200 且 `state=draft`,紧接着的 `select` 指名它**仍然** 400。物化发生在**发布**时,所以「有没有保存」是错的问题,「服务端有没有」才是——答案在 `layered().effective` 这条基线里。

因此新增 `publishedFieldNames`(加载时取自基线),并让 `gridColumns` 只保留其中存在的列。新字段照常在右侧检查器里被选中和配置(那本来就是配置它的地方),发布之后它可查询了,才作为列出现在网格里。

过滤放在列数组这一处、而不是取数那一侧,是为了让「网格要什么」只有一个真相源。

回归测试 `DataPillar.gridProjection.test.tsx` 断言在**交给对象视图的列数组**上——那个数组就是投影本身;如果只监视取数调用,日后把数组在下游改一手也能过,缺陷就悄悄搬回来了。撤掉这一行过滤,第二条精确转红、第一条仍绿。
Original file line numberDiff line numberDiff line change
@@ -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_<N>` 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<typeof import('../metadata-admin/useMetadata')>();
return {
...mod,
useMetadataClient: () => mockClient,
useMetadataTypes: () => ({ entries: [] }),
};
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
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<Record<string, unknown>>();
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 <div data-testid="grid-columns">{cols.join(',')}</div>;
},
};
});

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(
<MemoryRouter initialEntries={[`/studio/${packageId}/data`]}>
<SurfaceDeepLinkProvider>
<DataPillar packageId={packageId} />
</SurfaceDeepLinkProvider>
</MemoryRouter>,
);
}

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_<N>`; 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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Set<string>>(new Set());
// The package's object-name namespace (framework#2694). New objects are
// auto-prefixed with `<namespace>_` so an author can never draft a prefix-less
// object that publish would later reject (code NAMESPACE_PREFIX).
Expand DownExpand Up@@ -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 {
Expand DownExpand Up@@ -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_<N>` 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],
);

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(app-shell): the Studio grid selects only fields the server has (cloud#1652) by os-zhuang · Pull Request #6281 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/grid-select-published-fields.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
'@object-ui/app-shell': patch
---

Studio 的记录网格只请求服务端真有的列——「添加字段」不再把整个网格打成「该视图的查询被拒绝」

在 Data 支柱点一下「添加字段」,整片网格立刻变成错误态,并建议「清除筛选条件」——而现场根本没有筛选条件。

根因是投影的来源错了。`gridColumns` 取的是**草稿**对象的字段名,而这个数组是下游取数的输入;`addField` 只往本地草稿推一个 `field_<N>`。于是列一变就重新取数,`select` 里带着服务端不存在的列,data API 按设计拒绝——它的错误信息还专门解释了为什么不能静默丢弃未知列:那会把窄投影悄悄答成宽投影。

修法的边界是**实测**出来的,不是猜的:把字段存成草稿返回 200 且 `state=draft`,紧接着的 `select` 指名它**仍然** 400。物化发生在**发布**时,所以「有没有保存」是错的问题,「服务端有没有」才是——答案在 `layered().effective` 这条基线里。

因此新增 `publishedFieldNames`(加载时取自基线),并让 `gridColumns` 只保留其中存在的列。新字段照常在右侧检查器里被选中和配置(那本来就是配置它的地方),发布之后它可查询了,才作为列出现在网格里。

过滤放在列数组这一处、而不是取数那一侧,是为了让「网格要什么」只有一个真相源。

回归测试 `DataPillar.gridProjection.test.tsx` 断言在**交给对象视图的列数组**上——那个数组就是投影本身;如果只监视取数调用,日后把数组在下游改一手也能过,缺陷就悄悄搬回来了。撤掉这一行过滤,第二条精确转红、第一条仍绿。
Original file line numberDiff line numberDiff line change
@@ -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_<N>` 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<typeof import('../metadata-admin/useMetadata')>();
return {
...mod,
useMetadataClient: () => mockClient,
useMetadataTypes: () => ({ entries: [] }),
};
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
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<Record<string, unknown>>();
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 <div data-testid="grid-columns">{cols.join(',')}</div>;
},
};
});

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(
<MemoryRouter initialEntries={[`/studio/${packageId}/data`]}>
<SurfaceDeepLinkProvider>
<DataPillar packageId={packageId} />
</SurfaceDeepLinkProvider>
</MemoryRouter>,
);
}

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_<N>`; 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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Set<string>>(new Set());
// The package's object-name namespace (framework#2694). New objects are
// auto-prefixed with `<namespace>_` so an author can never draft a prefix-less
// object that publish would later reject (code NAMESPACE_PREFIX).
Expand DownExpand Up@@ -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 {
Expand DownExpand Up@@ -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_<N>` 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],
);

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(app-shell): the Studio grid selects only fields the server has (cloud#1652) by os-zhuang · Pull Request #6281 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/grid-select-published-fields.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
'@object-ui/app-shell': patch
---

Studio 的记录网格只请求服务端真有的列——「添加字段」不再把整个网格打成「该视图的查询被拒绝」

在 Data 支柱点一下「添加字段」,整片网格立刻变成错误态,并建议「清除筛选条件」——而现场根本没有筛选条件。

根因是投影的来源错了。`gridColumns` 取的是**草稿**对象的字段名,而这个数组是下游取数的输入;`addField` 只往本地草稿推一个 `field_<N>`。于是列一变就重新取数,`select` 里带着服务端不存在的列,data API 按设计拒绝——它的错误信息还专门解释了为什么不能静默丢弃未知列:那会把窄投影悄悄答成宽投影。

修法的边界是**实测**出来的,不是猜的:把字段存成草稿返回 200 且 `state=draft`,紧接着的 `select` 指名它**仍然** 400。物化发生在**发布**时,所以「有没有保存」是错的问题,「服务端有没有」才是——答案在 `layered().effective` 这条基线里。

因此新增 `publishedFieldNames`(加载时取自基线),并让 `gridColumns` 只保留其中存在的列。新字段照常在右侧检查器里被选中和配置(那本来就是配置它的地方),发布之后它可查询了,才作为列出现在网格里。

过滤放在列数组这一处、而不是取数那一侧,是为了让「网格要什么」只有一个真相源。

回归测试 `DataPillar.gridProjection.test.tsx` 断言在**交给对象视图的列数组**上——那个数组就是投影本身;如果只监视取数调用,日后把数组在下游改一手也能过,缺陷就悄悄搬回来了。撤掉这一行过滤,第二条精确转红、第一条仍绿。
Original file line numberDiff line numberDiff line change
@@ -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_<N>` 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<typeof import('../metadata-admin/useMetadata')>();
return {
...mod,
useMetadataClient: () => mockClient,
useMetadataTypes: () => ({ entries: [] }),
};
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
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<Record<string, unknown>>();
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 <div data-testid="grid-columns">{cols.join(',')}</div>;
},
};
});

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(
<MemoryRouter initialEntries={[`/studio/${packageId}/data`]}>
<SurfaceDeepLinkProvider>
<DataPillar packageId={packageId} />
</SurfaceDeepLinkProvider>
</MemoryRouter>,
);
}

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_<N>`; 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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Set<string>>(new Set());
// The package's object-name namespace (framework#2694). New objects are
// auto-prefixed with `<namespace>_` so an author can never draft a prefix-less
// object that publish would later reject (code NAMESPACE_PREFIX).
Expand DownExpand Up@@ -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 {
Expand DownExpand Up@@ -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_<N>` 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],
);

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(app-shell): the Studio grid selects only fields the server has (cloud#1652) by os-zhuang · Pull Request #6281 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/grid-select-published-fields.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
'@object-ui/app-shell': patch
---

Studio 的记录网格只请求服务端真有的列——「添加字段」不再把整个网格打成「该视图的查询被拒绝」

在 Data 支柱点一下「添加字段」,整片网格立刻变成错误态,并建议「清除筛选条件」——而现场根本没有筛选条件。

根因是投影的来源错了。`gridColumns` 取的是**草稿**对象的字段名,而这个数组是下游取数的输入;`addField` 只往本地草稿推一个 `field_<N>`。于是列一变就重新取数,`select` 里带着服务端不存在的列,data API 按设计拒绝——它的错误信息还专门解释了为什么不能静默丢弃未知列:那会把窄投影悄悄答成宽投影。

修法的边界是**实测**出来的,不是猜的:把字段存成草稿返回 200 且 `state=draft`,紧接着的 `select` 指名它**仍然** 400。物化发生在**发布**时,所以「有没有保存」是错的问题,「服务端有没有」才是——答案在 `layered().effective` 这条基线里。

因此新增 `publishedFieldNames`(加载时取自基线),并让 `gridColumns` 只保留其中存在的列。新字段照常在右侧检查器里被选中和配置(那本来就是配置它的地方),发布之后它可查询了,才作为列出现在网格里。

过滤放在列数组这一处、而不是取数那一侧,是为了让「网格要什么」只有一个真相源。

回归测试 `DataPillar.gridProjection.test.tsx` 断言在**交给对象视图的列数组**上——那个数组就是投影本身;如果只监视取数调用,日后把数组在下游改一手也能过,缺陷就悄悄搬回来了。撤掉这一行过滤,第二条精确转红、第一条仍绿。
Original file line numberDiff line numberDiff line change
@@ -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_<N>` 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<typeof import('../metadata-admin/useMetadata')>();
return {
...mod,
useMetadataClient: () => mockClient,
useMetadataTypes: () => ({ entries: [] }),
};
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
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<Record<string, unknown>>();
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 <div data-testid="grid-columns">{cols.join(',')}</div>;
},
};
});

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(
<MemoryRouter initialEntries={[`/studio/${packageId}/data`]}>
<SurfaceDeepLinkProvider>
<DataPillar packageId={packageId} />
</SurfaceDeepLinkProvider>
</MemoryRouter>,
);
}

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_<N>`; 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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Set<string>>(new Set());
// The package's object-name namespace (framework#2694). New objects are
// auto-prefixed with `<namespace>_` so an author can never draft a prefix-less
// object that publish would later reject (code NAMESPACE_PREFIX).
Expand DownExpand Up@@ -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 {
Expand DownExpand Up@@ -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_<N>` 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],
);

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(app-shell): the Studio grid selects only fields the server has (cloud#1652) by os-zhuang · Pull Request #6281 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/grid-select-published-fields.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
'@object-ui/app-shell': patch
---

Studio 的记录网格只请求服务端真有的列——「添加字段」不再把整个网格打成「该视图的查询被拒绝」

在 Data 支柱点一下「添加字段」,整片网格立刻变成错误态,并建议「清除筛选条件」——而现场根本没有筛选条件。

根因是投影的来源错了。`gridColumns` 取的是**草稿**对象的字段名,而这个数组是下游取数的输入;`addField` 只往本地草稿推一个 `field_<N>`。于是列一变就重新取数,`select` 里带着服务端不存在的列,data API 按设计拒绝——它的错误信息还专门解释了为什么不能静默丢弃未知列:那会把窄投影悄悄答成宽投影。

修法的边界是**实测**出来的,不是猜的:把字段存成草稿返回 200 且 `state=draft`,紧接着的 `select` 指名它**仍然** 400。物化发生在**发布**时,所以「有没有保存」是错的问题,「服务端有没有」才是——答案在 `layered().effective` 这条基线里。

因此新增 `publishedFieldNames`(加载时取自基线),并让 `gridColumns` 只保留其中存在的列。新字段照常在右侧检查器里被选中和配置(那本来就是配置它的地方),发布之后它可查询了,才作为列出现在网格里。

过滤放在列数组这一处、而不是取数那一侧,是为了让「网格要什么」只有一个真相源。

回归测试 `DataPillar.gridProjection.test.tsx` 断言在**交给对象视图的列数组**上——那个数组就是投影本身;如果只监视取数调用,日后把数组在下游改一手也能过,缺陷就悄悄搬回来了。撤掉这一行过滤,第二条精确转红、第一条仍绿。
Original file line numberDiff line numberDiff line change
@@ -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_<N>` 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<typeof import('../metadata-admin/useMetadata')>();
return {
...mod,
useMetadataClient: () => mockClient,
useMetadataTypes: () => ({ entries: [] }),
};
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
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<Record<string, unknown>>();
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 <div data-testid="grid-columns">{cols.join(',')}</div>;
},
};
});

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(
<MemoryRouter initialEntries={[`/studio/${packageId}/data`]}>
<SurfaceDeepLinkProvider>
<DataPillar packageId={packageId} />
</SurfaceDeepLinkProvider>
</MemoryRouter>,
);
}

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_<N>`; 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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Set<string>>(new Set());
// The package's object-name namespace (framework#2694). New objects are
// auto-prefixed with `<namespace>_` so an author can never draft a prefix-less
// object that publish would later reject (code NAMESPACE_PREFIX).
Expand DownExpand Up@@ -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 {
Expand DownExpand Up@@ -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_<N>` 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],
);

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(app-shell): the Studio grid selects only fields the server has (cloud#1652) by os-zhuang · Pull Request #6281 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/grid-select-published-fields.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
'@object-ui/app-shell': patch
---

Studio 的记录网格只请求服务端真有的列——「添加字段」不再把整个网格打成「该视图的查询被拒绝」

在 Data 支柱点一下「添加字段」,整片网格立刻变成错误态,并建议「清除筛选条件」——而现场根本没有筛选条件。

根因是投影的来源错了。`gridColumns` 取的是**草稿**对象的字段名,而这个数组是下游取数的输入;`addField` 只往本地草稿推一个 `field_<N>`。于是列一变就重新取数,`select` 里带着服务端不存在的列,data API 按设计拒绝——它的错误信息还专门解释了为什么不能静默丢弃未知列:那会把窄投影悄悄答成宽投影。

修法的边界是**实测**出来的,不是猜的:把字段存成草稿返回 200 且 `state=draft`,紧接着的 `select` 指名它**仍然** 400。物化发生在**发布**时,所以「有没有保存」是错的问题,「服务端有没有」才是——答案在 `layered().effective` 这条基线里。

因此新增 `publishedFieldNames`(加载时取自基线),并让 `gridColumns` 只保留其中存在的列。新字段照常在右侧检查器里被选中和配置(那本来就是配置它的地方),发布之后它可查询了,才作为列出现在网格里。

过滤放在列数组这一处、而不是取数那一侧,是为了让「网格要什么」只有一个真相源。

回归测试 `DataPillar.gridProjection.test.tsx` 断言在**交给对象视图的列数组**上——那个数组就是投影本身;如果只监视取数调用,日后把数组在下游改一手也能过,缺陷就悄悄搬回来了。撤掉这一行过滤,第二条精确转红、第一条仍绿。
Original file line numberDiff line numberDiff line change
@@ -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_<N>` 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<typeof import('../metadata-admin/useMetadata')>();
return {
...mod,
useMetadataClient: () => mockClient,
useMetadataTypes: () => ({ entries: [] }),
};
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
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<Record<string, unknown>>();
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 <div data-testid="grid-columns">{cols.join(',')}</div>;
},
};
});

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(
<MemoryRouter initialEntries={[`/studio/${packageId}/data`]}>
<SurfaceDeepLinkProvider>
<DataPillar packageId={packageId} />
</SurfaceDeepLinkProvider>
</MemoryRouter>,
);
}

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_<N>`; 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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Set<string>>(new Set());
// The package's object-name namespace (framework#2694). New objects are
// auto-prefixed with `<namespace>_` so an author can never draft a prefix-less
// object that publish would later reject (code NAMESPACE_PREFIX).
Expand DownExpand Up@@ -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 {
Expand DownExpand Up@@ -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_<N>` 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],
);

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); fix(app-shell): the Studio grid selects only fields the server has (cloud#1652) by os-zhuang · Pull Request #6281 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/grid-select-published-fields.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
'@object-ui/app-shell': patch
---

Studio 的记录网格只请求服务端真有的列——「添加字段」不再把整个网格打成「该视图的查询被拒绝」

在 Data 支柱点一下「添加字段」,整片网格立刻变成错误态,并建议「清除筛选条件」——而现场根本没有筛选条件。

根因是投影的来源错了。`gridColumns` 取的是**草稿**对象的字段名,而这个数组是下游取数的输入;`addField` 只往本地草稿推一个 `field_<N>`。于是列一变就重新取数,`select` 里带着服务端不存在的列,data API 按设计拒绝——它的错误信息还专门解释了为什么不能静默丢弃未知列:那会把窄投影悄悄答成宽投影。

修法的边界是**实测**出来的,不是猜的:把字段存成草稿返回 200 且 `state=draft`,紧接着的 `select` 指名它**仍然** 400。物化发生在**发布**时,所以「有没有保存」是错的问题,「服务端有没有」才是——答案在 `layered().effective` 这条基线里。

因此新增 `publishedFieldNames`(加载时取自基线),并让 `gridColumns` 只保留其中存在的列。新字段照常在右侧检查器里被选中和配置(那本来就是配置它的地方),发布之后它可查询了,才作为列出现在网格里。

过滤放在列数组这一处、而不是取数那一侧,是为了让「网格要什么」只有一个真相源。

回归测试 `DataPillar.gridProjection.test.tsx` 断言在**交给对象视图的列数组**上——那个数组就是投影本身;如果只监视取数调用,日后把数组在下游改一手也能过,缺陷就悄悄搬回来了。撤掉这一行过滤,第二条精确转红、第一条仍绿。
Original file line numberDiff line numberDiff line change
@@ -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_<N>` 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<typeof import('../metadata-admin/useMetadata')>();
return {
...mod,
useMetadataClient: () => mockClient,
useMetadataTypes: () => ({ entries: [] }),
};
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
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<Record<string, unknown>>();
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 <div data-testid="grid-columns">{cols.join(',')}</div>;
},
};
});

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(
<MemoryRouter initialEntries={[`/studio/${packageId}/data`]}>
<SurfaceDeepLinkProvider>
<DataPillar packageId={packageId} />
</SurfaceDeepLinkProvider>
</MemoryRouter>,
);
}

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_<N>`; 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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Set<string>>(new Set());
// The package's object-name namespace (framework#2694). New objects are
// auto-prefixed with `<namespace>_` so an author can never draft a prefix-less
// object that publish would later reject (code NAMESPACE_PREFIX).
Expand DownExpand Up@@ -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 {
Expand DownExpand Up@@ -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_<N>` 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],
);

/**
Expand Down
Loading