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/.changeset/tool-title-i18n-channel.md b/.changeset/tool-title-i18n-channel.md new file mode 100644 index 0000000000..1b2b1d13af --- /dev/null +++ b/.changeset/tool-title-i18n-channel.md @@ -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.` 查;查不到就回落到与今天完全一致的英文标题。 + +**这一步只打通通道,不改变任何现有显示**:不传 translate 时行为逐字不变(测试的第一组 +就在钉这一点),语言包也还没有条目。后续两件事各自独立、可分别推进: +把两个调用点接上 `useSafeTranslate()`;以及按需往语言包里补 `chatbot.tool.*`。 +先落通道是因为——在通道存在之前,翻译工作根本无处可放。 + +回落刻意交给英文标题而非原始名:语言包缺条目时显示 `Describe object`(与今天相同), +而不是 `describe_object`(比今天更差)。 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], ); /** diff --git a/packages/plugin-chatbot/src/__tests__/tool-display-i18n.test.ts b/packages/plugin-chatbot/src/__tests__/tool-display-i18n.test.ts new file mode 100644 index 0000000000..91021cf6c5 --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/tool-display-i18n.test.ts @@ -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 = { + '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' }); + }); +}); diff --git a/packages/plugin-chatbot/src/tool-display.ts b/packages/plugin-chatbot/src/tool-display.ts index 4e0e36dfeb..c665635de6 100644 --- a/packages/plugin-chatbot/src/tool-display.ts +++ b/packages/plugin-chatbot/src/tool-display.ts @@ -23,15 +23,46 @@ const HUMAN_WORDS: Record = { 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.`; 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 ''; @@ -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]; @@ -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; } /**