Skip to content

fix(app-shell): 运行时工具栏保存筛选前把 FilterGroup 折成 spec 的 ViewFilterRule[] - #3360

Merged
xuyushun441-sys merged 3 commits into
mainfrom
claude/issue-5159-filter-fold
Aug 4, 2026
Merged

fix(app-shell): 运行时工具栏保存筛选前把 FilterGroup 折成 spec 的 ViewFilterRule[]#3360
xuyushun441-sys merged 3 commits into
mainfrom
claude/issue-5159-filter-fold

Conversation

@xuyushun441-sys

Copy link
Copy Markdown
Contributor

修复 objectstack-ai/objectstack#5159

维护者裁决 A1:修生产者 —— objectui 在持久化前把 filter-builder 的 FilterGroup 折叠成 spec 声明的 ViewFilterRule[],复用 Studio 侧已有的同一变换;logic: 'or' 与嵌套分组响亮拒绝,⛔ 绝不静默降级成 and;spec 零改动,不放宽 union。

问题

列表工具栏 Filter → Add filter → 保存 时,onFilterChange 的载荷被原样交给 persistViewPatch({ filter })。那个载荷是 filter-builder 自己的分组方言 { id, logic, conditions },而 ListViewSchema.filter 声明的是 z.array(ViewFilterRuleSchema) —— 每次保存都是 422 invalid_union,筛选条件从未真正落盘。因为 objectstack#5014 把 union 报错压平成不含 filter 字样的 Invalid input,它在 main 上一直没被发现。

改动

新增 packages/app-shell/src/views/viewFilterFold.ts,承载写方向的折叠(读方向 parseSpecFilter / toFilterGroup 早已存在于 plugin-view)。该变换从 Studio 侧检查器 metadata-admin/widgets.tsxFilterBuilderField 提取而来 —— 那里原本是唯一一份拷贝,正因如此运行时工具栏一份都没有。现在两处走同一个函数,不再是两套方言。

  • 算子经 spec 自己导出的 normalizeFilterOperator 归一。widgets.tsx 原来那张手维护的 FB_TO_SPEC 表已随之删除:它比 builder 下拉框落后了四个算子(startsWith / endsWith / isNull / isNotNull),这些此前会被原样写进库里。spec 不认识的算子原样透传,让服务端 enum 响亮拒绝,而不是被强行coerce 成 equals
  • 空行丢弃(Add filter 在用户选列前插入的 field: '' 行)。
  • id 剥离,见下。

logic: 'or' / 嵌套分组 —— 响亮拒绝(A1)

无法无损折成扁平 rule 列表的形状拒绝保存,并通过 toast.error(t(...)) 给出 i18n 文案(10 个语言包均已补齐 console.objectView.filterOrNotSavable / filterNestedNotSavable)。理由:把 OR 悄悄按 AND 写回去,存下来的视图会返回与用户屏幕上不同的记录集。该筛选仍然作用于当前列表(convertFilterGroupToAST 在会话内尊重 logic),只是不进入保存的视图。

边界:logic: 'or'少于两条有效规则时折叠而非拒绝 —— 0 或 1 条时 OR 与 AND 选出完全相同的记录,没有任何东西可丢失。拒绝从语义真正分叉处开始。

id 的去留 —— 实测后剥离

议题给的回放矩阵里变体 ② (rule[] 保留 id) 记为 ACCEPTED,但那是打在跑 framework main(#5154 容忍未声明键)的服务端上。对着 objectui 自己 pin 的 spec(17.0.0-rc.2)实测,ViewFilterRuleSchemastrictObject({field, operator, value}),带 id 的规则被 unrecognized_keys 拒在 filter.0:

rule WITH id -> REJECTED  unrecognized_keys: ... on this view filter rule: `id`
rule NO id   -> ACCEPTED  {"field":"title","operator":"equals","value":""}

所以剥 id 不只是「落盘形状更干净」,在本仓的 pin 下它是必须 —— 保留只会把一个 422 换成另一个 422。另一独立理由:读方向本就重新生成 id(parseTriplet 无条件 crypto.randomUUID(),parseSingleOrNested / toFilterGroup 兜底),builder 往返无损。两条都已入测试。

验证

验证层级:payload-boundary + 单元(未驱动真浏览器)。折叠产物直接对服务端所用的同一个 ListViewSchema 断言,回放矩阵三变体在测试里闭合:

变体 断言
① 原样 FilterGroup ListViewSchema REJECTED,且失败路径确为 filter
rule[] 保留 id REJECTED,路径 filter.0 / unrecognized_keys
rule[]id(折叠产物) ACCEPTED

另有 view-filter-fold.ratchet.test.ts 守住持久化路径本身:对变换做形状断言,若将来某次改动把裸 group 绕过它就一文不值。该 ratchet 已反向验证 —— 手工把 persistViewFilter 改回 persistViewPatch(…, { filter }) 后,两条断言如期失败(this onFilterChange bypasses the fold),恢复后复绿。

vitest packages/app-shell/src/views/viewFilterFold.test.ts \
       packages/app-shell/src/views/view-filter-fold.ratchet.test.ts
  Test Files  2 passed (2)       Tests  31 passed (31)

vitest packages/app-shell packages/i18n
  Test Files  291 passed (291)   Tests  2537 passed | 1 skipped (2538)

pnpm --filter @object-ui/app-shell type-check   -> exit 0, 0 errors
pnpm --filter @object-ui/app-shell lint         -> 0 errors (2160 warnings, 仓库既有基线)
pnpm --filter @object-ui/i18n     lint          -> 0 errors

已含 changeset(patch:@object-ui/app-shell / @object-ui/i18n)。未触碰 releases 文档。

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9


Generated by Claude Code

claude added 3 commits August 4, 2026 07:48
… before persist

WIP checkpoint — implementation complete, verification pending.

objectstack#5159: the list toolbar's `Filter -> Add filter -> save` PUT the
FilterBuilder's whole `FilterGroup` ({id, logic, conditions}) into the view's
`filter`, where `ListViewSchema.filter` declares `z.array(ViewFilterRuleSchema)`.
The server answered 422 `invalid_union` on every save.

Fix the PRODUCER (AGENTS.md #0.1) — the spec is untouched, no union widening:

- New `packages/app-shell/src/views/viewFilterFold.ts` holds the WRITE half of
  the bridge whose READ half is plugin-view's `parseSpecFilter`/`toFilterGroup`.
  Extracted from the Studio inspector's `FilterBuilderField`, which had the only
  copy — which is exactly why the runtime toolbar had none.
- Operators normalize through the spec's own `normalizeFilterOperator` instead
  of the local `FB_TO_SPEC` table that copy carried; that table had drifted four
  builder operators behind (startsWith/endsWith/isNull/isNotNull).
- Row `id` is stripped: the read path regenerates it (`parseSingleOrNested` does
  `item.id || crypto.randomUUID()`), so the at-rest body stays the declared
  {field, operator, value} vocabulary only.
- A1 adjudication: a group that cannot fold losslessly — `logic: 'or'` across
  2+ conditions, or a nested group — is REFUSED with a translated toast and not
  saved. Never silently downgraded to AND. `logic:'or'` over <2 rules folds,
  because there OR and AND select the same records.
- localStorage keeps the builder's group verbatim (it rehydrates the toolbar);
  only the spec-governed view body is folded.

Remaining steps:
1. app-shell + i18n test/lint/typecheck runs
2. changeset
3. draft PR

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
Adds replay-matrix variant ② to `viewFilterFold.test.ts` and records the
measurement in the fold's contract docs.

The issue's matrix recorded variant ② (flat `ViewFilterRule[]` still carrying
the FilterBuilder's row `id`) as ACCEPTED — but that was measured against a
server running framework `main`, where #5154 tolerates the undeclared key.
Measured against the spec objectui itself pins (17.0.0-rc.2),
`ViewFilterRuleSchema` is a `strictObject` over `{field, operator, value}` and
rejects it with `unrecognized_keys` on `filter.0`.

So stripping `id` is not merely the tidier at-rest shape — under this pin it is
required, and a fold that kept it would have traded a 422 `invalid_union` for a
422 `unrecognized_keys`. The new tests pin both that rejection and the fact that
the fold strips the key, so a later spec bump that declares `id` shows up here
rather than as a silent behaviour change.

Refs: objectstack-ai/objectstack#5159
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 4, 2026 1:35pm

Request Review

@github-actions github-actions Bot added the tests label Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-C7Q_kDbR.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.47KB 3.09KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 18.38KB 4.49KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 3.65KB 1.42KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.25KB 0.53KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 477.09KB 104.71KB
core (index.js) 2.25KB 0.80KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 136.23KB 34.75KB
fields (index.js) 226.73KB 55.54KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.46KB 0.96KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 5.37KB 1.72KB
i18n (useObjectLabel.js) 26.14KB 6.07KB
i18n (useSafeTranslation.js) 3.26KB 1.44KB
layout (index.js) 38.53KB 10.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.05KB 1.53KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 44.98KB 12.37KB
plugin-charts (index.js) 60.83KB 17.25KB
plugin-chatbot (index.js) 180.09KB 42.72KB
plugin-dashboard (index.js) 112.01KB 28.86KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 230.77KB 56.86KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 111.54KB 26.97KB
plugin-gantt (index.js) 162.25KB 39.55KB
plugin-grid (index.js) 185.08KB 49.04KB
plugin-kanban (index.js) 47.89KB 13.18KB
plugin-list (index.js) 104.94KB 25.32KB
plugin-map (index.js) 16.81KB 5.24KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.55KB 10.59KB
plugin-timeline (index.js) 25.76KB 7.33KB
plugin-tree (index.js) 8.34KB 2.82KB
plugin-view (index.js) 83.67KB 20.43KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 19.28KB 6.38KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.46KB 1.21KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 0.20KB 0.18KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

@xuyushun441-sys
xuyushun441-sys marked this pull request as ready for review August 4, 2026 14:27
@xuyushun441-sys
xuyushun441-sys added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 68b6a28 Aug 4, 2026
16 checks passed
@xuyushun441-sys
xuyushun441-sys deleted the claude/issue-5159-filter-fold branch August 4, 2026 14:27
Sign up for free to 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.

2 participants