Skip to content
Open
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 number Diff line number Diff 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` 断言在**交给对象视图的列数组**上——那个数组就是投影本身;如果只监视取数调用,日后把数组在下游改一手也能过,缺陷就悄悄搬回来了。撤掉这一行过滤,第二条精确转红、第一条仍绿。
29 changes: 29 additions & 0 deletions .changeset/tool-title-i18n-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@object-ui/plugin-chatbot': patch
---

工具卡片的名字终于有了 i18n 通道(此前中文界面里必然是英文)

实测(cloud#1658,全中文环境):

```
统计一下每个阅读状态各有多少本书
Describe object 已完成 执行过程 ← 工具名英文
Visualize data 已完成 执行过程 ← 工具名英文
已统计完成,各阅读状态的书本数量如下:… ← 其余全中文
```

卡片上每一处都本地化了——状态、动作、回答——**唯独工具名不能**,因为
`humanizeToolName` 是个纯英文构词器(`describe_object` → `Describe object`),
名字从未经过翻译,任何语言包都够不着它。而"它现在在做什么"恰恰是用户最需要读懂的一步。

现在它接受一个可选的 `translate`(形状即 `useSafeTranslate()`),按
`chatbot.tool.<tool_name>` 查;查不到就回落到与今天完全一致的英文标题。

**这一步只打通通道,不改变任何现有显示**:不传 translate 时行为逐字不变(测试的第一组
就在钉这一点),语言包也还没有条目。后续两件事各自独立、可分别推进:
把两个调用点接上 `useSafeTranslate()`;以及按需往语言包里补 `chatbot.tool.*`。
先落通道是因为——在通道存在之前,翻译工作根本无处可放。

回落刻意交给英文标题而非原始名:语言包缺条目时显示 `Describe object`(与今天相同),
而不是 `describe_object`(比今天更差)。
Original file line number Diff line number Diff 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);
});
});
30 changes: 28 additions & 2 deletions packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
Original file line number Diff line number Diff 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 Down Expand 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 Down Expand 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
79 changes: 79 additions & 0 deletions packages/plugin-chatbot/src/__tests__/tool-display-i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Tool titles can be localized — cloud#1658.
*
* Measured in a fully Chinese conversation: every string on the tool card was
* localized except the tool's own name.
*
* Describe object 已完成 执行过程
* Visualize data 已完成 执行过程
* 已统计完成,各阅读状态的书本数量如下:…
*
* The step the user most needs to read — "what is it doing right now?" — was
* the one left in a foreign language, because `humanizeToolName` was an English
* title-caser with no i18n channel at all: the name never passed through
* translation, so no locale pack could reach it.
*
* The channel is OPTIONAL on purpose. Without a translator this function must
* behave exactly as it always has — that is what makes the change safe to land
* before every call site is wired and before any pack carries entries. The
* first suite below is therefore the load-bearing one: it pins the old
* behaviour, unchanged.
*/

import { describe, it, expect } from 'vitest';
import { humanizeToolName, toolTitleKey } from '../tool-display.js';

describe('humanizeToolName — unchanged without a translator', () => {
it('still title-cases snake_case and kebab-case', () => {
expect(humanizeToolName('list_objects')).toBe('List objects');
expect(humanizeToolName('query_records')).toBe('Query records');
expect(humanizeToolName('describe-api-tool')).toBe('Describe API tool');
});

it('still keeps the acronym casing table', () => {
expect(humanizeToolName('fetch_url')).toBe('Fetch URL');
expect(humanizeToolName('ai_summary')).toBe('AI summary');
});

it('still answers empty for empty input', () => {
expect(humanizeToolName('')).toBe('');
expect(humanizeToolName(undefined)).toBe('');
expect(humanizeToolName(null)).toBe('');
});
});

describe('humanizeToolName — with a translator', () => {
it('looks the title up under a stable per-tool key', () => {
expect(toolTitleKey('describe_object')).toBe('chatbot.tool.describe_object');
});

it('returns what the locale has', () => {
const zh: Record<string, string> = {
'chatbot.tool.describe_object': '查看对象结构',
'chatbot.tool.visualize_data': '生成图表',
};
const tt = (key: string, fallback: string) => zh[key] ?? fallback;
expect(humanizeToolName('describe_object', tt)).toBe('查看对象结构');
expect(humanizeToolName('visualize_data', tt)).toBe('生成图表');
});

it('falls back to the English title for a tool the pack does not carry', () => {
// The case that decides whether this is safe to ship gradually: a custom or
// newly added tool must read exactly as it does today, not as a raw key.
const tt = (_key: string, fallback: string) => fallback;
expect(humanizeToolName('some_custom_tool', tt)).toBe('Some custom tool');
});

it('hands the translator the English title as the fallback, not the raw name', () => {
// If the fallback were the raw `snake_case`, a missing entry would show
// `describe_object` — a regression from today's behaviour.
let seen: { key: string; fallback: string } | null = null;
humanizeToolName('describe_object', (key, fallback) => {
seen = { key, fallback };
return fallback;
});
expect(seen).toEqual({ key: 'chatbot.tool.describe_object', fallback: 'Describe object' });
});
});
45 changes: 40 additions & 5 deletions packages/plugin-chatbot/src/tool-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,46 @@ const HUMAN_WORDS: Record<string, string> = {
utc: 'UTC',
};

/**
* A translator for tool titles. Takes the i18n key and the English fallback
* this module would otherwise have produced, and returns whatever the active
* locale has — or the fallback when that locale carries no entry.
*
* Shaped to accept `useSafeTranslate()` directly, so a caller passes the hook's
* result and nothing else changes.
*/
export type ToolTitleTranslator = (key: string, fallback: string) => string;

/** The i18n key a tool title is looked up under. */
export function toolTitleKey(name: string): string {
return `chatbot.tool.${String(name).trim()}`;
}

/**
* Convert a snake_case / kebab-case tool name into a human-readable title.
*
* With no translator this is what it always was: an ENGLISH title-caser. That
* is why a fully Chinese conversation still read
* `Describe object 已完成 / Visualize data 已完成` (cloud#1658) — every other
* string on the card was localized and the tool name could not be, because the
* name never passed through i18n at all. The step the user most needs to read
* ("what is it doing right now?") was the one left in a foreign language.
*
* Pass `translate` (e.g. `useSafeTranslate()`) to look the title up as
* `chatbot.tool.<tool_name>`; a locale with no entry for that tool falls back
* to the same English title as before, so packs can be filled in gradually and
* an unknown//custom tool is never worse off than today.
*
* @example
* humanizeToolName('list_objects') // → 'List objects'
* humanizeToolName('query_records') // → 'Query records'
* humanizeToolName('describe-api-tool') // → 'Describe API tool'
* humanizeToolName('list_objects') // → 'List objects'
* humanizeToolName('query_records') // → 'Query records'
* humanizeToolName('describe-api-tool') // → 'Describe API tool'
* humanizeToolName('describe_object', tt) // → '查看对象结构' (zh)
*/
export function humanizeToolName(name: string | undefined | null): string {
export function humanizeToolName(
name: string | undefined | null,
translate?: ToolTitleTranslator,
): string {
if (!name) return '';
const trimmed = String(name).trim();
if (!trimmed) return '';
Expand All @@ -41,7 +72,7 @@ export function humanizeToolName(name: string | undefined | null): string {
.split(/\s+/)
.filter(Boolean);
if (words.length === 0) return trimmed;
return words
const english = words
.map((word, idx) => {
const lower = word.toLowerCase();
if (HUMAN_WORDS[lower]) return HUMAN_WORDS[lower];
Expand All @@ -51,6 +82,10 @@ export function humanizeToolName(name: string | undefined | null): string {
return lower;
})
.join(' ');
// The English title is the FALLBACK, not a second choice computed only on
// miss: building it either way keeps this function total and side-effect
// free, and costs one string join.
return translate ? translate(toolTitleKey(trimmed), english) : english;
}

/**
Expand Down
Loading