Skip to content

fix(app-shell): 把 Save as view 的 URL 下钻条件折成 spec ViewFilterRule (#3419) - #3427

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3419-saveasview-filter-fold
Aug 6, 2026
Merged

fix(app-shell): 把 Save as view 的 URL 下钻条件折成 spec ViewFilterRule (#3419)#3427
yinlianghui merged 1 commit into
mainfrom
claude/issue-3419-saveasview-filter-fold

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3419

前提复核(先于实现)

issue 的前提在 origin/main(已含 #3420 的 viewEnvelope spec pin)上仍然成立,只是行号漂了:handleSaveAsView 现在在 ObjectDataPage.tsx:250 附近,那一行 ...(urlFilters.length ? { filter: urlFilters } : {}) 原样未动。用 lockfile 解析到的 @objectstack/spec@17.0.0-rc.2 复现,拿到的就是 issue 里那条:

config.filter = [['stage','=','open']]
ViewItemSchema.safeParse(env) -> false
[{"expected":"object","code":"invalid_type","path":["config","filter",0],
  "message":"Invalid input: expected object, received array"}]

顺带核实了一个 issue 没写、但决定实现形状的事实:normalizeFilterOperator 并不认识符号操作符。它只折 spec 自己的词形别名(eq / gte / notEquals …),symbols 会原样返回:

"="  -> "="   ruleValid=false
">=" -> ">="  ruleValid=false
"eq" -> "equals"                ruleValid=true
"gte"-> "greater_than_or_equal" ruleValid=true

所以「直接把三元组的 operator 丢给 normalizeFilterOperator」是不够的,见下面的桥接表。

改法(producer 侧折叠,契约优先)

这个页面渲染用的是运行时 filter AST(三元组,ListView 直接当 $filter 发出去);持久化的是 ViewItem,ListViewSchema.filter 声明的是 z.array(ViewFilterRuleSchema)。同一个 filter 键、两套词汇表 —— 原样写出去的那份从落盘那一刻起就过不了 record gate。

按 AGENTS.md #0.1,折叠放在 producer,没有给任何消费侧加三元组容忍:

  1. 符号 → spec 别名桥接(TRIPLE_OP_TO_SPEC_ALIAS)。这不是第二张规范映射表:range 那半边是把 URL_FILTER_OPS 反转出来的(它的后缀 gte/lte/gt/lt 本身就是 spec 的别名 key),所以 URL 契约以后新增 range 操作符会自动被桥接;只有 = 是手写的,因为等值形式没有 [op] 后缀可反转。
  2. 规范化仍然只发生在 normalizeFilterOperator 一处 —— 与 viewFilterFold.foldFilterGroupToSpecRules(FilterBuilder 那一半,objectstack#5159)同一个出口。= 折成 equals,>= 折成 greater_than_or_equal
  3. field / value 原样带过,'' 也保留(spec 接受,重写等于偷改用户存的东西)。
  4. 没有规范拼写的 operator 直接从被持久化的 view 里丢掉,只留一条 debug 级提示,而不是写一条 off-spec rule ——「声明即强制」。整条 body 过不了 gate 的话在 publish 时是整体被拒的,会连带丢掉用户其它条件,所以丢一条比毁一整条 view 好。parseUrlFilterTriples 今天只会吐上面那五个操作符,这条路径是防 URL 契约以后长出 spec 没有对应词的操作符的纵深防御。
  5. 没有下钻条件时的行为逐字节不变 —— 折叠结果为空就完全不写 filter 键,和以前一样。

为什么多出一个 buildSaveAsViewSpec

spec 组装从回调里提出来成了一个导出函数。理由是可验证性:只测折叠 helper 的话,call site 退回 filter: urlFilters 时测试依然全绿 —— 那正是要钉住的回归。现在测试打的是「producer 那一整步 + 真 record gate」。剩下唯一没被单测覆盖的一环是回调调用它的那一行,这个边界在下面的反向验证里如实标了。

运行时那条路径(schema memo 里的 filter: urlFilters)故意没动:那是喂给 ListView 的 AST,三元组正是它要的形状,改了会真的改坏筛选。

测试

新增 packages/app-shell/src/views/ObjectDataPage.saveAsViewFilterFold.test.ts(10 例):等值下钻、日期桶下钻(同字段两条 range)、URL 契约能吐的每个 operator(从 URL_FILTER_OPS 派生,新增的漏不掉)、field/value 原样带过、无规范拼写时丢弃 + 只保留其余、全丢时不写 filter 键,以及「无下钻条件时 spec 与对话框 payload 完全一致」。issue 里那条 zod 报错也作为可执行证据钉住了(它断言的是 spec 的判定,改前改后都绿)。

反向验证(方向在跑之前就定好):把 buildSaveAsViewSpec 里的折叠退回原始 urlFilters,预测「折叠类用例红、spec 判定那条和无下钻那三条绿」。实测完全一致 —— 6 红 4 绿:

× folds an equality drill and the envelope passes the ViewItem gate
  -> expected [ [ 'stage', '=', 'open' ] ] to deeply equal [ { field: 'stage', …(2) } ]
× folds EVERY operator the URL contract can emit to a canonical spelling
  -> expected [ undefined, undefined, …(3) ] to deeply equal [ 'equals', …(4) ]
× omits `filter` entirely when every triple was dropped
  -> expected true to be false
✓ documents the pre-fix rejection: a raw triple is not a ViewFilterRule
✓ writes no `filter` key and keeps the dialog payload byte-identical

全量结果:

  • pnpm exec vitest run packages/app-shell281 files / 2444 passed, 1 skipped
  • pnpm --filter @object-ui/app-shell type-check → 通过(先跑了 --filter '@object-ui/app-shell^...' build)
  • pnpm exec eslint(改动文件)→ 0 error;新增的 react-refresh/only-export-components warn 是组件文件导出 helper 的既有形态(ObjectView.tsx 同样有一条),规则本身就是 warn
  • node scripts/check-control-bytes.mjs → OK;另对改动文件做了 grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f]' 自查,无命中
  • check-changeset-fixed / check-changeset-no-major → 通过(patch,@object-ui/app-shell)

消费半径扫过一遍:全仓只有 ObjectDataPage.tsx 和新增测试引用这条保存路径,其余命中都是 i18n 文案,没有别的包的 fixture 拼了三元组 view filter。


Generated by Claude Code

…terRules (#3419)

ObjectDataPage renders from a runtime filter AST (FilterTriple, i.e.
[field, '=', value]), but "Save as view" persists a ViewItem whose
ListViewSchema.filter declares z.array(ViewFilterRuleSchema) --
{ field, operator, value } over the canonical operator words. The two
were the same key in two vocabularies: handleSaveAsView wrote the
triples through verbatim, so a view saved with drill conditions active
was off-spec the moment it was written (ViewItemSchema rejects it on
config.filter.0, "expected object, received array").

Contract-first (AGENTS.md #0.1): the fold is at the PRODUCER, no
consumer is taught to accept triples. Operators go through the spec's
own normalizeFilterOperator -- the same exit viewFilterFold uses for
the FilterBuilder half of this problem -- via a symbol-to-alias bridge
derived by inverting URL_FILTER_OPS, so canonicalisation still happens
in exactly one place. field/value are carried verbatim.

A triple whose operator has no canonical spelling is dropped from the
persisted view with a debug-level note rather than written off-spec
(declared = enforced; parseUrlFilterTriples emits no such operator
today, so this is defence in depth against the URL contract growing
one). Saving with no drill conditions is byte-identical to before.

The spec assembly moved into an exported buildSaveAsViewSpec so the
whole producer step -- not just the fold helper -- is pinned against
the real record gate; a test on the fold alone would stay green if the
call site went back to raw triples.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@vercel

vercel Bot commented Aug 6, 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 6, 2026 3:21am

Request Review

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

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-WSP3XvS4.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) 478.80KB 105.18KB
core (index.js) 2.47KB 0.91KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 136.23KB 34.75KB
fields (index.js) 229.34KB 56.32KB
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.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
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) 61.04KB 17.31KB
plugin-chatbot (index.js) 180.09KB 42.72KB
plugin-dashboard (index.js) 112.03KB 28.88KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 232.48KB 57.34KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 111.54KB 26.97KB
plugin-gantt (index.js) 162.55KB 39.57KB
plugin-grid (index.js) 185.08KB 49.04KB
plugin-kanban (index.js) 47.89KB 13.18KB
plugin-list (index.js) 105.02KB 25.36KB
plugin-map (index.js) 16.81KB 5.24KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 6, 2026 03:34
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit 875c5fa Aug 6, 2026
17 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3419-saveasview-filter-fold branch August 6, 2026 03:34
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.

ObjectDataPage「Save as view」把 URL filter 三元组原样写进 ViewItem,产出过不了 ViewItemSchema

2 participants