Skip to content

fix(i18n,list): the sort panel's relational hint points at a stored denormalised field, not a formula field (#4294) - #4334

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4294-sort-hint-reword
Aug 11, 2026
Merged

fix(i18n,list): the sort panel's relational hint points at a stored denormalised field, not a formula field (#4294)#4334
yinlianghui merged 1 commit into
mainfrom
claude/issue-4294-sort-hint-reword

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#4294

The Sort panel withholds columns that link to another record and explains why. The last sentence of that explanation named the remedy — "To sort by that name, add a formula field holding it" — and a formula field is the one type the platform will not order by. The server keeps UNMATERIALIZED_SORT_TYPES = new Set(['formula']), and since objectstack#6994 a sort naming one is a hard 400 INVALID_SORT; before that it degraded silently, returning every row with asc and desc byte-identical. Since #4243 withheld formula fields from this very picker, an author who followed the hint also arrived at a field the panel does not offer. Two doors, opposite advice, one problem.

The wording, and where it comes from

The remedy now names a stored, denormalised field, written when the source changes, and rules the formula field out in as many words. That is deliberately the server's own vocabulary rather than a third phrasing: objectstack#6924 and objectstack#6994 settled on one wording across the refusal doors so an author refused twice is not sent two different ways.

Server wording source, read read-only at objectstack origin/main @ 5d24f4b94:

  • packages/metadata-protocol/src/protocol.ts:5046-5049 — the hint on the unmaterialized-type refusal door (objectstack#6994), verbatim:

    Denormalise the value onto '{object}' (a stored field, written when the source changes) and sort by that. A formula field is virtual: with no column behind it the ORDER BY reaches the driver, finds nothing, and is dropped — the arbitrary order this refusal replaces.

  • packages/metadata-protocol/src/protocol.ts:4993-4996 — the same remedy sentence on the dotted-path door, which is what "one vocabulary across the doors" means in practice.

  • packages/metadata-protocol/src/protocol.ts:1593UNMATERIALIZED_SORT_TYPES. (The triage comment cites :1903; on today's main the constant sits at :1593. Same constant, same set, line drift only.)

New en value:

Columns that link to another record are not listed: they can only be sorted by the stored ID, not by the name shown in the cell. To sort by that name, denormalize it onto this object as a stored field, written when the source changes, and sort by that. Not a formula field: it is virtual, so no column is stored for it and the server refuses to sort by one.

The first half — why relation columns are withheld — is unchanged in every pack. Only the remedy sentence moved. Spelling follows this repo rather than the server's British form: packages/plugin-list/README.md already writes "denormalize" for this exact remedy.

All ten packs, and one copy that is not a pack

Ten locale packs updated (packages/i18n/src/locales/*.ts), as check:i18n-drift requires of any en edit. Each translation matches its pack's existing register for "field" / "stored" / "formula" — the terms were taken from neighbouring keys in the same pack (the formula field-type label, the sort* cluster, the import notices' word for "server"), not machine-translated.

One edit outside the packs, and it is load-bearing:packages/plugin-list/src/ListView.tsx holds LIST_DEFAULT_TRANSLATIONS, the table createSafeTranslation serves when no I18nProvider is mounted, and it carried a byte-identical copy of the old sentence. That copy is updated to match en exactly. This is not belt-and-braces — it is measured. Reverting only that file, with all ten packs already holding the new text, leaves the rendered panel serving the retired advice:

FAIL packages/plugin-list/src/__tests__/ListView.relationalSort.test.tsx
Expected element to have text content:
/denormalize it onto this object as a stored field/i
Received:
Columns that link to another record are not listed: ... To sort by that name,
add a formula field holding it.

So a pack-only reword would have left the old advice on exactly the surface this card is about, wherever the component is used outside the console. No gate can see that: check:i18n-keys judges inline t(key, { defaultValue }) options at call sites, never a createSafeTranslation table, and the drift gate compares packs to packs. Flagging it as a deliberate, reviewable step beyond the dispatched file list — it is the same key and a pure string swap, no logic touched.

Tests

No existing assertion needed updating. Both suites that read this hint (ListView.relationalSort.test.tsx:88, ListView.sortFieldFallback.test.tsx:227) match on /can only be sorted by the stored ID/i, which lives in the unchanged first half.

One pin added to ListView.relationalSort.test.tsx — it asserts the remedy the hint gives and the one it must never give again, since "formula" still appears in the sentence, named only to be ruled out.

Red-first is real here, in both directions:

  • The drift gate, reverting one pack (zh) to origin/main while en holds the new text: EXIT=1, unchanged in: zh. Restored, green: "1 en value(s) changed, 9 pack value(s) followed ... Every changed en value was followed by all nine translation packs."
  • The new pin, reverting ListView.tsx only: the failure quoted above. Restored, green.

Gates and suites run locally, all green:

node scripts/check-i18n-en-drift.mjs # 1 en changed, 9 packs followed
node scripts/check-i18n-call-site-keys.mjs # 2813 keys, 844 inline defaults match
node scripts/check-control-bytes.mjs # 4052 files scanned
node scripts/check-changeset-presence.mjs # 12 src files / 2 released packages / 1 changeset
vitest run packages/i18n packages/plugin-list/src/__tests__/ListView.relationalSort.test.tsx \
packages/plugin-list/src/__tests__/ListView.sortFieldFallback.test.tsx
-> Test Files 40 passed (40) | Tests 675 passed (675)
pnpm --filter @object-ui/i18n --filter @object-ui/plugin-list type-check # both tsc runs each, Done

Changeset: patch for @object-ui/i18n and @object-ui/plugin-list.

Out of scope, filed separately

packages/plugin-list/README.md:229-230 gives the same retired advice in prose ("denormalize it onto this object with a formula field"). Different surface, its own explanatory paragraph to rewrite — filed rather than ridden along, per the card's scope note.


Generated by Claude Code

…enormalised field, not a formula field (#4294)
`list.sortRelationalHint` told an author to "add a formula field holding
it" — the one field type the server refuses to order by
(`UNMATERIALIZED_SORT_TYPES`, a hard 400 INVALID_SORT since
objectstack#6994, and withheld from this very picker since #4243).
The remedy sentence now names a stored, denormalised field written when
the source changes, and rules the formula field out explicitly, in the
server's own refusal-door vocabulary (objectstack#6924/#6994 keep one
wording across the doors). All ten packs move together, as
check:i18n-drift requires. plugin-list's provider-less fallback table
carries the identical sentence, since that copy — not the pack — is what
renders outside an I18nProvider.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercelBot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 11, 2026 3:43pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)29.5 KB350 KB
Entry fileindex-B-F3dWAB.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.56KB3.59KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)489.10KB108.43KB
core (index.js)3.04KB1.15KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)150.04KB39.79KB
fields (index.js)228.45KB56.62KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)16.38KB5.47KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.98KB10.85KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)45.23KB12.45KB
plugin-charts (index.js)62.18KB17.67KB
plugin-chatbot (index.js)180.33KB42.79KB
plugin-dashboard (index.js)121.58KB31.58KB
plugin-designer (index.js)210.91KB42.67KB
plugin-detail (index.js)238.95KB59.76KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.58KB27.68KB
plugin-gantt (index.js)164.14KB39.98KB
plugin-grid (index.js)188.00KB49.94KB
plugin-kanban (index.js)48.60KB13.41KB
plugin-list (index.js)110.10KB26.74KB
plugin-map (index.js)18.05KB5.80KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.60KB10.58KB
plugin-timeline (index.js)26.21KB7.52KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)23.71KB7.96KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.23KB0.66KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.05KB1.52KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[i18n] The list sort panel's relational hint recommends a formula field, which the server refuses to sort by (400)

2 participants

@yinlianghui@claude