@elytracms/studio 0.0.50 → 0.0.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/convex/auth.ts CHANGED
@@ -1,11 +1,140 @@
1
1
  import { convexAuth } from '@convex-dev/auth/server'
2
2
  import { Password } from '@convex-dev/auth/providers/Password'
3
+ import type { AnyDataModel, GenericMutationCtx } from 'convex/server'
3
4
  import { ConvexError } from 'convex/values'
5
+ import type { GenericId } from 'convex/values'
4
6
  import { classifyInviteSignup, normalizeInviteEmail } from '@elytracms/core/persistence'
7
+ import type { InviteSignupDecision } from '@elytracms/core/persistence'
5
8
 
6
9
  /** The provider list `convexAuth` accepts, derived from its own signature. */
7
10
  type ConvexAuthProviders = Parameters<typeof convexAuth>[0]['providers']
8
11
 
12
+ /** The (non-schema-bound) mutation ctx the `convexAuth` callbacks receive. */
13
+ type AuthCallbackCtx = GenericMutationCtx<AnyDataModel>
14
+
15
+ /**
16
+ * Gather the invite-gate facts for a principal and classify them (EC-324).
17
+ * The ONE decision lives in the pure `classifyInviteSignup`
18
+ * (`@elytracms/core/persistence`, shared with its tests):
19
+ *
20
+ * - **existing member** → normal re-sign-in; no-op (also recovers a member-less
21
+ * account it created earlier and never lets the deployment lock itself out).
22
+ * - **first user** (no members yet) → bootstrap as `admin`.
23
+ * - **invited** → grant the invited role (+ consume the invite).
24
+ * - **uninvited** → REJECT (closes the EC-304 open-sign-up hole).
25
+ */
26
+ async function decideInviteGate(
27
+ ctx: AuthCallbackCtx,
28
+ args: { userId: GenericId<'users'>; email: string | null },
29
+ ): Promise<{ decision: InviteSignupDecision; invite: { _id: string; role: string } | null }> {
30
+ // The convexAuth callback ctx is `AnyDataModel` (not schema-bound), so use
31
+ // `.filter()` rather than `.withIndex()` here. The members/invites tables are
32
+ // tiny (single-tenant), and this runs only at account creation / sign-in.
33
+ const existingMember = await ctx.db
34
+ .query('members')
35
+ .filter((q) => q.eq(q.field('userId'), args.userId))
36
+ .first()
37
+ const anyMember = existingMember ?? (await ctx.db.query('members').first())
38
+ const email = args.email ? normalizeInviteEmail(args.email) : null
39
+ const invite = email
40
+ ? await ctx.db
41
+ .query('invites')
42
+ .filter((q) => q.eq(q.field('email'), email))
43
+ .first()
44
+ : null
45
+
46
+ const decision = classifyInviteSignup({
47
+ hasAnyMember: anyMember !== null,
48
+ existingRole: existingMember?.role ?? null,
49
+ invite: invite ? { role: invite.role } : null,
50
+ })
51
+ return { decision, invite }
52
+ }
53
+
54
+ /**
55
+ * EC-358 — the ACCOUNT-CREATION gate. Wired as `afterUserCreatedOrUpdated`,
56
+ * which Convex Auth calls inside the SAME `auth:store` mutation that creates
57
+ * the principal's rows — after the `users` insert, **before** the
58
+ * `authAccounts` insert (`upsertUserAndAccount`). A Convex mutation is one
59
+ * atomic transaction, so throwing here rolls the `users` insert back and the
60
+ * `authAccounts` row is never written: an uninvited sign-up leaves ZERO rows.
61
+ *
62
+ * Why this hook: the EC-324 gate lived only in `beforeSessionCreation`, which
63
+ * runs in a LATER store mutation — by then the password `authAccounts` + `users`
64
+ * rows were already committed, so a rejected uninvited sign-up left an orphan
65
+ * account carrying an attacker-chosen password that a later invite would
66
+ * silently promote (proven live 2026-07-08). The alternative pre-create hook,
67
+ * `createOrUpdateUser`, REPLACES the library's user-creation/linking mechanics
68
+ * wholesale — a far bigger seam than this scope allows; this hook keeps the
69
+ * default mechanics and only adds the veto.
70
+ *
71
+ * Only REJECT throws here; membership granting/consuming stays in
72
+ * `beforeSessionCreation` (the account-creation callback also fires for
73
+ * verification/OAuth account flows where a session may not follow immediately).
74
+ * For credentials providers this fires exactly on `createAccount` (sign-UP),
75
+ * not on re-sign-in.
76
+ */
77
+ export async function gateAccountCreation(
78
+ ctx: AuthCallbackCtx,
79
+ args: { userId: GenericId<'users'>; profile: { email?: string } },
80
+ ): Promise<void> {
81
+ const email = typeof args.profile.email === 'string' ? args.profile.email : null
82
+ const { decision } = await decideInviteGate(ctx, { userId: args.userId, email })
83
+ if (decision.kind === 'reject') throw new ConvexError(decision.reason)
84
+ }
85
+
86
+ /**
87
+ * Invite-based onboarding gate + first-principal bootstrap (EC-324 / EC-150).
88
+ * Wired as `beforeSessionCreation`: fires on EVERY sign-in (not just account
89
+ * creation), right before the session is persisted — throwing here rejects the
90
+ * sign-in. See {@link decideInviteGate} for the decision table.
91
+ *
92
+ * The `ConvexError` message ("not-invited: …") survives to the client even in
93
+ * production, so the sign-in screen tells the truth instead of the old
94
+ * "password too short". The `ConvexAuthAdapter` classifies it to a distinct
95
+ * `not-invited` `SignInResult` reason.
96
+ *
97
+ * Single-tenant: one `members` table per deployment. Invite mechanism: a plain
98
+ * email allowlist (`invites` table) — the simplest server-enforced design, no
99
+ * token infrastructure.
100
+ */
101
+ export async function gateSessionCreation(
102
+ ctx: AuthCallbackCtx,
103
+ args: { userId: GenericId<'users'> },
104
+ ): Promise<void> {
105
+ const now = new Date().toISOString()
106
+ const user = await ctx.db.get(args.userId)
107
+ const rawEmail = (user as { email?: string } | null)?.email
108
+ const { decision, invite } = await decideInviteGate(ctx, {
109
+ userId: args.userId,
110
+ email: rawEmail ?? null,
111
+ })
112
+
113
+ switch (decision.kind) {
114
+ case 'existing-member':
115
+ return
116
+ case 'bootstrap-admin':
117
+ await ctx.db.insert('members', {
118
+ userId: args.userId,
119
+ role: 'admin',
120
+ createdAt: now,
121
+ updatedAt: now,
122
+ })
123
+ return
124
+ case 'grant-role':
125
+ await ctx.db.insert('members', {
126
+ userId: args.userId,
127
+ role: decision.role,
128
+ createdAt: now,
129
+ updatedAt: now,
130
+ })
131
+ if (invite) await ctx.db.delete(invite._id as GenericId<'invites'>)
132
+ return
133
+ case 'reject':
134
+ throw new ConvexError(decision.reason)
135
+ }
136
+ }
137
+
9
138
  /**
10
139
  * EC-252 — the studio's Convex Auth (EC-150) as a **default + override seam**. Only the
11
140
  * PROVIDER choice is legitimately consumer-specific; the first-principal admin bootstrap
@@ -30,69 +159,13 @@ export function defineStudioAuth(options?: { providers?: ConvexAuthProviders })
30
159
  return convexAuth({
31
160
  providers: options?.providers ?? [Password],
32
161
  callbacks: {
33
- /**
34
- * Invite-based onboarding gate + first-principal bootstrap (EC-324 / EC-150).
35
- * Fires on EVERY sign-in (not just account creation), right before the session
36
- * is persisted — throwing here rejects the sign-in. The ONE decision lives in
37
- * the pure `classifyInviteSignup` (shared with its tests):
38
- *
39
- * - **existing member** → normal re-sign-in; no-op (also recovers a member-less
40
- * account it created earlier and never lets the deployment lock itself out).
41
- * - **first user** (no members yet) → bootstrap as `admin` (was the only case).
42
- * - **invited** → grant the invited role + CONSUME the invite (delete the row).
43
- * - **uninvited** → REJECT. This closes the open-sign-up hole (EC-304): an
44
- * anonymous person can no longer self-register into the deployment. The
45
- * `ConvexError` message ("not-invited: …") survives to the client even in
46
- * production, so the sign-in screen tells the truth.
47
- *
48
- * Single-tenant: one `members` table per deployment. Invite mechanism: a plain
49
- * email allowlist (`invites` table) — the simplest server-enforced design, no
50
- * token infrastructure.
51
- */
162
+ /** EC-358: reject uninvited account creation BEFORE any row commits. */
163
+ async afterUserCreatedOrUpdated(ctx, { userId, profile }) {
164
+ await gateAccountCreation(ctx, { userId, profile })
165
+ },
166
+ /** EC-324: the every-sign-in gate — bootstrap / grant / consume / reject. */
52
167
  async beforeSessionCreation(ctx, { userId }) {
53
- const now = new Date().toISOString()
54
- // The convexAuth callback ctx is `AnyDataModel` (not schema-bound), so use
55
- // `.filter()` rather than `.withIndex()` here. The members/invites tables are
56
- // tiny (single-tenant), and this runs only at sign-in.
57
- const existingMember = await ctx.db
58
- .query('members')
59
- .filter((q) => q.eq(q.field('userId'), userId))
60
- .first()
61
- const anyMember = existingMember ?? (await ctx.db.query('members').first())
62
- const user = await ctx.db.get(userId)
63
- const rawEmail = (user as { email?: string } | null)?.email
64
- const email = rawEmail ? normalizeInviteEmail(rawEmail) : null
65
- const invite = email
66
- ? await ctx.db
67
- .query('invites')
68
- .filter((q) => q.eq(q.field('email'), email))
69
- .first()
70
- : null
71
-
72
- const decision = classifyInviteSignup({
73
- hasAnyMember: anyMember !== null,
74
- existingRole: existingMember?.role ?? null,
75
- invite: invite ? { role: invite.role } : null,
76
- })
77
-
78
- switch (decision.kind) {
79
- case 'existing-member':
80
- return
81
- case 'bootstrap-admin':
82
- await ctx.db.insert('members', { userId, role: 'admin', createdAt: now, updatedAt: now })
83
- return
84
- case 'grant-role':
85
- await ctx.db.insert('members', {
86
- userId,
87
- role: decision.role,
88
- createdAt: now,
89
- updatedAt: now,
90
- })
91
- if (invite) await ctx.db.delete(invite._id)
92
- return
93
- case 'reject':
94
- throw new ConvexError(decision.reason)
95
- }
168
+ await gateSessionCreation(ctx, { userId })
96
169
  },
97
170
  },
98
171
  })
package/convex/invites.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { v } from 'convex/values'
2
+ import { selectOrphanCredentialPurges } from '@elytracms/core/persistence'
2
3
  import { mutation, query } from './functions'
4
+ import type { MutationCtx } from './functions'
3
5
  import { requireCapability } from './guard'
4
6
 
5
7
  /**
@@ -17,6 +19,78 @@ import { requireCapability } from './guard'
17
19
 
18
20
  const normalizeEmail = (email: string): string => email.trim().toLowerCase()
19
21
 
22
+ /**
23
+ * EC-358 — an invite must never adopt pre-existing orphan credentials.
24
+ *
25
+ * Threat: an attacker pre-registers a password for someone's email; before
26
+ * EC-358's account-creation gate that left an orphan (member-less)
27
+ * `users` + `authAccounts` pair, and a later invite for that email would have
28
+ * silently promoted the ATTACKER's credentials at their next sign-in. Creating
29
+ * an invite is therefore the purge chokepoint: purging at gate-promotion time
30
+ * would be too late by construction — the promotion is *triggered by* whoever
31
+ * holds the password. Purged here, the invited person always sets their own.
32
+ *
33
+ * WHAT is purged, per member-less user matching the invited email (selection is
34
+ * the pure `selectOrphanCredentialPurges`, normalized-email matched): their
35
+ * `authSessions` (+ `authRefreshTokens`), `authAccounts`
36
+ * (+ `authVerificationCodes`), and the `users` row itself — so no stale session
37
+ * or reset code survives the credential purge. A user with a `members` row is
38
+ * NEVER touched, whatever their email. Runs in the invite-creation mutation, so
39
+ * purge + invite land in one atomic transaction.
40
+ *
41
+ * The `users` table is tiny (single-tenant studio principals), so the
42
+ * collect-and-match scan is fine — and required, because the auth provider
43
+ * stores emails as typed at sign-up (an index lookup on the normalized email
44
+ * would miss case-varied orphans the sign-in gate would still promote).
45
+ */
46
+ export async function purgeOrphanCredentials(
47
+ ctx: Pick<MutationCtx, 'db'>,
48
+ invitedEmail: string,
49
+ ): Promise<number> {
50
+ const members = await ctx.db.query('members').collect()
51
+ const memberIds = new Set(members.map((member) => member.userId))
52
+ const users = await ctx.db.query('users').collect()
53
+ const purgeIds = new Set(
54
+ selectOrphanCredentialPurges(
55
+ invitedEmail,
56
+ users.map((user) => ({
57
+ id: String(user._id),
58
+ email: user.email ?? null,
59
+ isMember: memberIds.has(String(user._id)),
60
+ })),
61
+ ),
62
+ )
63
+ const orphans = users.filter((user) => purgeIds.has(String(user._id)))
64
+ for (const orphan of orphans) {
65
+ const sessions = await ctx.db
66
+ .query('authSessions')
67
+ .withIndex('userId', (q) => q.eq('userId', orphan._id))
68
+ .collect()
69
+ for (const session of sessions) {
70
+ const tokens = await ctx.db
71
+ .query('authRefreshTokens')
72
+ .withIndex('sessionId', (q) => q.eq('sessionId', session._id))
73
+ .collect()
74
+ for (const token of tokens) await ctx.db.delete(token._id)
75
+ await ctx.db.delete(session._id)
76
+ }
77
+ const accounts = await ctx.db
78
+ .query('authAccounts')
79
+ .withIndex('userIdAndProvider', (q) => q.eq('userId', orphan._id))
80
+ .collect()
81
+ for (const account of accounts) {
82
+ const codes = await ctx.db
83
+ .query('authVerificationCodes')
84
+ .withIndex('accountId', (q) => q.eq('accountId', account._id))
85
+ .collect()
86
+ for (const code of codes) await ctx.db.delete(code._id)
87
+ await ctx.db.delete(account._id)
88
+ }
89
+ await ctx.db.delete(orphan._id)
90
+ }
91
+ return orphans.length
92
+ }
93
+
20
94
  export const list = query({
21
95
  args: {},
22
96
  handler: async (ctx) => {
@@ -37,6 +111,9 @@ export const create = mutation({
37
111
  handler: async (ctx, args) => {
38
112
  await requireCapability(ctx, 'project.administer')
39
113
  const email = normalizeEmail(args.email)
114
+ // EC-358: never adopt orphan credentials — purge member-less auth rows for
115
+ // this email atomically with the invite, so the invitee sets their own password.
116
+ await purgeOrphanCredentials(ctx, email)
40
117
  const existing = await ctx.db
41
118
  .query('invites')
42
119
  .withIndex('by_email', (q) => q.eq('email', email))
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Boxes, Check, Image, Link2, Minus } from 'lucide-react';
3
3
  import { readAssetId, readDocumentRef } from '@elytracms/core/cms-core';
4
4
  import { blockRootsOf } from '@elytracms/core/project-graph';
5
+ import { richTextPlainText } from '@elytracms/core/rich-text';
5
6
  import { Badge, Text } from '@elytracms/studio/ui';
6
7
  import { translate } from '@elytracms/studio/lib/i18n';
7
8
  import { localeChipLabel, resolveDisplayedValue } from '@elytracms/studio/lib/locale';
@@ -48,7 +49,7 @@ function TypedCellValue({ field, value }) {
48
49
  case 'date':
49
50
  return (_jsx(Text, { size: "sm", tone: "muted", mono: true, children: String(value).slice(0, 10) }));
50
51
  case 'richText':
51
- return (_jsx(Text, { size: "xs", tone: "subtle", children: stripHtml(String(value)).slice(0, 40) || translate('cms.cell.richText') }));
52
+ return (_jsx(Text, { size: "xs", tone: "subtle", children: richTextCellSummary(value).slice(0, 40) || translate('cms.cell.richText') }));
52
53
  case 'relation':
53
54
  return _jsx(RelationCell, { value: value });
54
55
  case 'asset':
@@ -152,4 +153,20 @@ export function toRefArray(value) {
152
153
  function stripHtml(html) {
153
154
  return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
154
155
  }
156
+ /**
157
+ * EC-348 — a richText field's stored value is the rich-text envelope
158
+ * (`{ version, doc }` TipTap JSON, EC-314), not an HTML string: `stripHtml`
159
+ * alone here yielded the literal `[object Object]`. Fallback chain: a valid
160
+ * envelope extracts to plain text (`richTextPlainText`); a legacy plain/HTML
161
+ * string (pre-EC-314 content, or any other non-envelope string) still goes
162
+ * through `stripHtml` as before; anything else (garbage, wrong field-type data)
163
+ * yields `''` so the cell's placeholder shows — never a crash, never
164
+ * `[object Object]`.
165
+ */
166
+ function richTextCellSummary(value) {
167
+ const extracted = richTextPlainText(value);
168
+ if (extracted !== undefined)
169
+ return extracted;
170
+ return typeof value === 'string' ? stripHtml(value) : '';
171
+ }
155
172
  //# sourceMappingURL=cell.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"cell.js","sourceRoot":"","sources":["../../../src/components/cms/cell.tsx"],"names":[],"mappings":"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","sourcesContent":["import { Boxes, Check, Image, Link2, Minus } from 'lucide-react'\nimport { readAssetId, readDocumentRef } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CollectionDef, DocumentRef, FieldDef } from '@elytracms/core/cms-core'\nimport { blockRootsOf } from '@elytracms/core/project-graph'\nimport { Badge, Text } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport { localeChipLabel, resolveDisplayedValue } from '@elytracms/studio/lib/locale'\nimport type { DisplayedFallback } from '@elytracms/studio/lib/locale'\nimport type { ResolvedAsset } from '@elytracms/studio/lib/collection-view/collection-view-source'\nimport { useCollectionView } from '../collection-view/collection-view-context'\nimport { AssetThumb, useAssetRecord } from '../assets/asset-thumb'\n\n/**\n * Render a document's field value for a table cell, by field type (brief\n * §8.4, EC-070), resolved in the studio's ambient active locale (EC-176).\n * A value served via EC-018 locale fallback carries a visible chip — never\n * silently mixed in as if it were translated.\n */\nexport function CellValue({\n collection,\n doc,\n field,\n}: {\n collection: CollectionDef\n doc: CmsDocument\n field: FieldDef\n}) {\n const source = useCollectionView()\n const { value, fallback } = resolveDisplayedValue(\n collection,\n doc,\n field.name,\n source.activeLocale,\n source.localeConfig,\n )\n\n if (value === undefined || value === null || value === '') {\n return <Minus className=\"size-3.5 text-muted-foreground/50\" />\n }\n\n return (\n <span className=\"inline-flex min-w-0 max-w-full items-center gap-1.5\">\n <TypedCellValue field={field} value={value} />\n <FallbackChip fallback={fallback} testId={`cell-fallback-${doc.id}-${field.name}`} />\n </span>\n )\n}\n\n/**\n * Subtle source-locale chip on a fallback-displayed value (EC-176): e.g. a\n * German view showing untranslated English content gets an \"EN\" marker.\n */\nexport function FallbackChip({\n fallback,\n testId,\n}: {\n fallback: DisplayedFallback | null\n testId: string\n}) {\n if (fallback === null) return null\n return (\n <Badge\n variant=\"outline\"\n data-testid={testId}\n title={fallbackTitle(fallback)}\n className=\"shrink-0 px-1 py-0 text-[0.6rem] uppercase tracking-wide text-muted-foreground\"\n >\n {localeChipLabel(fallback.sourceLocale)}\n </Badge>\n )\n}\n\n/** Plain-language tooltip of a fallback chip. */\nexport function fallbackTitle(fallback: DisplayedFallback): string {\n return translate('cms.cell.fallbackTitle', {\n source: localeChipLabel(fallback.sourceLocale),\n requested: localeChipLabel(fallback.requestedLocale),\n })\n}\n\nfunction TypedCellValue({ field, value }: { field: FieldDef; value: unknown }) {\n switch (field.type) {\n case 'boolean':\n return value === true ? (\n <Check className=\"size-4 text-success\" />\n ) : (\n <Minus className=\"size-3.5 text-muted-foreground/50\" />\n )\n case 'select':\n return <Badge variant=\"outline\">{String(value)}</Badge>\n case 'number':\n return <Text size=\"sm\" mono>{String(value)}</Text>\n case 'date':\n return (\n <Text size=\"sm\" tone=\"muted\" mono>\n {String(value).slice(0, 10)}\n </Text>\n )\n case 'richText':\n return (\n <Text size=\"xs\" tone=\"subtle\">\n {stripHtml(String(value)).slice(0, 40) || translate('cms.cell.richText')}\n </Text>\n )\n case 'relation':\n return <RelationCell value={value} />\n case 'asset':\n return <AssetCell value={value} />\n case 'blocks':\n return <BlocksCell value={value} />\n case 'object': {\n // EC-253: list-view summary, never `[object Object]`. A repeater shows its\n // item count; a single object is edited in the detail form, not the cell.\n if (field.multiple) {\n const count = Array.isArray(value) ? value.length : 0\n return (\n <Badge variant=\"outline\">\n {translate(count === 1 ? 'cms.cell.itemCount.one' : 'cms.cell.itemCount.other', { count })}\n </Badge>\n )\n }\n return (\n <Text size=\"xs\" tone=\"subtle\">\n {value && typeof value === 'object' ? translate('cms.cell.object') : '—'}\n </Text>\n )\n }\n default:\n return (\n <Text size=\"sm\" className=\"truncate\">\n {String(value)}\n </Text>\n )\n }\n}\n\nfunction RelationCell({ value }: { value: unknown }) {\n const source = useCollectionView()\n const refs = toRefArray(value)\n if (refs.length === 0) return <Minus className=\"size-3.5 text-muted-foreground/50\" />\n // EC-246c: calm de-pilled read — a single muted link glyph + the title(s), not\n // a row of info pills. Matches the relation FIELD's row treatment; the pill was\n // a design cue the list doesn't need.\n return (\n <span className=\"inline-flex min-w-0 items-center gap-1.5 text-sm\">\n <Link2 className=\"size-3.5 shrink-0 text-muted-foreground\" />\n <span className=\"min-w-0 truncate\">\n {refs.slice(0, 3).map((ref) => source.titleOf(ref)).join(', ')}\n </span>\n {refs.length > 3 ? (\n <Text size=\"xs\" tone=\"subtle\" className=\"shrink-0\">\n +{refs.length - 3}\n </Text>\n ) : null}\n </span>\n )\n}\n\n/**\n * A blocks/composition cell: a count of TOP-LEVEL blocks (EC-212) — matching\n * what the canvas/page-tree present as the block list, never `[object Object]`.\n */\nfunction BlocksCell({ value }: { value: unknown }) {\n const count = blockRootsOf(value).length\n if (count === 0) return <Minus className=\"size-3.5 text-muted-foreground/50\" />\n return (\n <Badge variant=\"outline\">\n <Boxes className=\"size-3\" />{' '}\n {translate(count === 1 ? 'cms.cell.blockCount.one' : 'cms.cell.blockCount.other', { count })}\n </Badge>\n )\n}\n\nfunction AssetCell({ value }: { value: unknown }) {\n const source = useCollectionView()\n const assetCollectionId = source.assetCollectionId ?? 'asset'\n // EC-277: tolerate self-typed `{ type:'asset', id }` envelopes and legacy bare ids.\n const ids = (Array.isArray(value) ? value : [value])\n .map(readAssetId)\n .filter((id): id is string => id !== undefined)\n if (ids.length === 0) return <Minus className=\"size-3.5 text-muted-foreground/50\" />\n // EC-346: multiple assets show the first, plus a \"+N\" count for the rest —\n // this IS the field cell, not a gallery editor.\n const first = ids[0]!\n return (\n <div className=\"flex flex-wrap items-center gap-1.5\">\n <AssetFieldThumb assetId={first} assetCollectionId={assetCollectionId} />\n {ids.length > 1 ? <Text size=\"xs\" tone=\"subtle\">+{ids.length - 1}</Text> : null}\n </div>\n )\n}\n\n/**\n * EC-346 — an asset-type FIELD column's cell in a document list: a small\n * thumbnail (`size-8`, matching the 0.0.39 filename-column pattern), reusing the\n * SAME {@link AssetThumb} the assets rail uses so a focal point (EC-230/248)\n * crops the preview identically everywhere it appears. Resolves the full record\n * from the ambient asset library (`useAssetRecord` — ProjectProviders already\n * mounts an `<AssetsProvider>` at the project level, EC-195, so every CMS list\n * surface sees it) and prefers it over the source's own summary because only the\n * ambient record carries the focal point. Falls back to the source's\n * `resolveAsset` label/best-effort-image (the AD-19 \"no `<AssetsProvider>`\"\n * degradation a portable rail — or a test double — without a mounted library\n * still needs) so this never regresses the prior behaviour. Never crashes on an\n * id that resolves to nothing — both paths render a placeholder, never a badge\n * defaulted from a missing record.\n */\nfunction AssetFieldThumb({\n assetId,\n assetCollectionId,\n}: {\n assetId: string\n assetCollectionId: string\n}) {\n const source = useCollectionView()\n const ambient = useAssetRecord(assetId)\n if (ambient) return <AssetThumb record={ambient} className=\"size-8 shrink-0\" />\n const resolved = source.resolveAsset?.(assetId)\n const label =\n resolved?.title ?? resolved?.filename ?? source.titleOf({ collection: assetCollectionId, id: assetId })\n return <AssetPreview record={resolved} label={label} />\n}\n\n/**\n * The pre-EC-346 fallback preview: an image thumbnail for image assets with a\n * resolvable URL (EC-212), otherwise the labelled icon badge (and the same\n * badge whenever the source can't resolve the record — e.g. no asset backend).\n * Used only when no ambient asset library is mounted (see {@link AssetFieldThumb}).\n */\nfunction AssetPreview({ record, label }: { record: ResolvedAsset | undefined; label: string }) {\n const isImage = record?.contentType?.startsWith('image/') ?? false\n const url = record?.url\n if (isImage && url && url.startsWith('http')) {\n return (\n <span className=\"inline-flex min-w-0 items-center gap-1.5\">\n <img\n src={url}\n alt={record?.alt ?? label}\n loading=\"lazy\"\n className=\"size-7 shrink-0 rounded border border-border bg-muted object-cover\"\n />\n <Text size=\"xs\" tone=\"subtle\" className=\"max-w-[10rem] truncate\">\n {label}\n </Text>\n </span>\n )\n }\n return (\n <Badge variant=\"outline\">\n <Image className=\"size-3\" /> {label}\n </Badge>\n )\n}\n\n/**\n * Normalize a relation value (single ref or array of refs) into an array of\n * `DocumentRef`s. EC-277: unwraps self-typed `{ type:'reference', ... }` envelopes\n * and legacy bare `{ collection, id }` via the shared tolerant reader.\n */\nexport function toRefArray(value: unknown): DocumentRef[] {\n const list = Array.isArray(value) ? value : [value]\n return list.map(readDocumentRef).filter((ref): ref is DocumentRef => ref !== undefined)\n}\n\nfunction stripHtml(html: string): string {\n return html.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n}\n"]}
1
+ {"version":3,"file":"cell.js","sourceRoot":"","sources":["../../../src/components/cms/cell.tsx"],"names":[],"mappings":"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","sourcesContent":["import { Boxes, Check, Image, Link2, Minus } from 'lucide-react'\nimport { readAssetId, readDocumentRef } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CollectionDef, DocumentRef, FieldDef } from '@elytracms/core/cms-core'\nimport { blockRootsOf } from '@elytracms/core/project-graph'\nimport { richTextPlainText } from '@elytracms/core/rich-text'\nimport { Badge, Text } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport { localeChipLabel, resolveDisplayedValue } from '@elytracms/studio/lib/locale'\nimport type { DisplayedFallback } from '@elytracms/studio/lib/locale'\nimport type { ResolvedAsset } from '@elytracms/studio/lib/collection-view/collection-view-source'\nimport { useCollectionView } from '../collection-view/collection-view-context'\nimport { AssetThumb, useAssetRecord } from '../assets/asset-thumb'\n\n/**\n * Render a document's field value for a table cell, by field type (brief\n * §8.4, EC-070), resolved in the studio's ambient active locale (EC-176).\n * A value served via EC-018 locale fallback carries a visible chip — never\n * silently mixed in as if it were translated.\n */\nexport function CellValue({\n collection,\n doc,\n field,\n}: {\n collection: CollectionDef\n doc: CmsDocument\n field: FieldDef\n}) {\n const source = useCollectionView()\n const { value, fallback } = resolveDisplayedValue(\n collection,\n doc,\n field.name,\n source.activeLocale,\n source.localeConfig,\n )\n\n if (value === undefined || value === null || value === '') {\n return <Minus className=\"size-3.5 text-muted-foreground/50\" />\n }\n\n return (\n <span className=\"inline-flex min-w-0 max-w-full items-center gap-1.5\">\n <TypedCellValue field={field} value={value} />\n <FallbackChip fallback={fallback} testId={`cell-fallback-${doc.id}-${field.name}`} />\n </span>\n )\n}\n\n/**\n * Subtle source-locale chip on a fallback-displayed value (EC-176): e.g. a\n * German view showing untranslated English content gets an \"EN\" marker.\n */\nexport function FallbackChip({\n fallback,\n testId,\n}: {\n fallback: DisplayedFallback | null\n testId: string\n}) {\n if (fallback === null) return null\n return (\n <Badge\n variant=\"outline\"\n data-testid={testId}\n title={fallbackTitle(fallback)}\n className=\"shrink-0 px-1 py-0 text-[0.6rem] uppercase tracking-wide text-muted-foreground\"\n >\n {localeChipLabel(fallback.sourceLocale)}\n </Badge>\n )\n}\n\n/** Plain-language tooltip of a fallback chip. */\nexport function fallbackTitle(fallback: DisplayedFallback): string {\n return translate('cms.cell.fallbackTitle', {\n source: localeChipLabel(fallback.sourceLocale),\n requested: localeChipLabel(fallback.requestedLocale),\n })\n}\n\nfunction TypedCellValue({ field, value }: { field: FieldDef; value: unknown }) {\n switch (field.type) {\n case 'boolean':\n return value === true ? (\n <Check className=\"size-4 text-success\" />\n ) : (\n <Minus className=\"size-3.5 text-muted-foreground/50\" />\n )\n case 'select':\n return <Badge variant=\"outline\">{String(value)}</Badge>\n case 'number':\n return <Text size=\"sm\" mono>{String(value)}</Text>\n case 'date':\n return (\n <Text size=\"sm\" tone=\"muted\" mono>\n {String(value).slice(0, 10)}\n </Text>\n )\n case 'richText':\n return (\n <Text size=\"xs\" tone=\"subtle\">\n {richTextCellSummary(value).slice(0, 40) || translate('cms.cell.richText')}\n </Text>\n )\n case 'relation':\n return <RelationCell value={value} />\n case 'asset':\n return <AssetCell value={value} />\n case 'blocks':\n return <BlocksCell value={value} />\n case 'object': {\n // EC-253: list-view summary, never `[object Object]`. A repeater shows its\n // item count; a single object is edited in the detail form, not the cell.\n if (field.multiple) {\n const count = Array.isArray(value) ? value.length : 0\n return (\n <Badge variant=\"outline\">\n {translate(count === 1 ? 'cms.cell.itemCount.one' : 'cms.cell.itemCount.other', { count })}\n </Badge>\n )\n }\n return (\n <Text size=\"xs\" tone=\"subtle\">\n {value && typeof value === 'object' ? translate('cms.cell.object') : '—'}\n </Text>\n )\n }\n default:\n return (\n <Text size=\"sm\" className=\"truncate\">\n {String(value)}\n </Text>\n )\n }\n}\n\nfunction RelationCell({ value }: { value: unknown }) {\n const source = useCollectionView()\n const refs = toRefArray(value)\n if (refs.length === 0) return <Minus className=\"size-3.5 text-muted-foreground/50\" />\n // EC-246c: calm de-pilled read — a single muted link glyph + the title(s), not\n // a row of info pills. Matches the relation FIELD's row treatment; the pill was\n // a design cue the list doesn't need.\n return (\n <span className=\"inline-flex min-w-0 items-center gap-1.5 text-sm\">\n <Link2 className=\"size-3.5 shrink-0 text-muted-foreground\" />\n <span className=\"min-w-0 truncate\">\n {refs.slice(0, 3).map((ref) => source.titleOf(ref)).join(', ')}\n </span>\n {refs.length > 3 ? (\n <Text size=\"xs\" tone=\"subtle\" className=\"shrink-0\">\n +{refs.length - 3}\n </Text>\n ) : null}\n </span>\n )\n}\n\n/**\n * A blocks/composition cell: a count of TOP-LEVEL blocks (EC-212) — matching\n * what the canvas/page-tree present as the block list, never `[object Object]`.\n */\nfunction BlocksCell({ value }: { value: unknown }) {\n const count = blockRootsOf(value).length\n if (count === 0) return <Minus className=\"size-3.5 text-muted-foreground/50\" />\n return (\n <Badge variant=\"outline\">\n <Boxes className=\"size-3\" />{' '}\n {translate(count === 1 ? 'cms.cell.blockCount.one' : 'cms.cell.blockCount.other', { count })}\n </Badge>\n )\n}\n\nfunction AssetCell({ value }: { value: unknown }) {\n const source = useCollectionView()\n const assetCollectionId = source.assetCollectionId ?? 'asset'\n // EC-277: tolerate self-typed `{ type:'asset', id }` envelopes and legacy bare ids.\n const ids = (Array.isArray(value) ? value : [value])\n .map(readAssetId)\n .filter((id): id is string => id !== undefined)\n if (ids.length === 0) return <Minus className=\"size-3.5 text-muted-foreground/50\" />\n // EC-346: multiple assets show the first, plus a \"+N\" count for the rest —\n // this IS the field cell, not a gallery editor.\n const first = ids[0]!\n return (\n <div className=\"flex flex-wrap items-center gap-1.5\">\n <AssetFieldThumb assetId={first} assetCollectionId={assetCollectionId} />\n {ids.length > 1 ? <Text size=\"xs\" tone=\"subtle\">+{ids.length - 1}</Text> : null}\n </div>\n )\n}\n\n/**\n * EC-346 — an asset-type FIELD column's cell in a document list: a small\n * thumbnail (`size-8`, matching the 0.0.39 filename-column pattern), reusing the\n * SAME {@link AssetThumb} the assets rail uses so a focal point (EC-230/248)\n * crops the preview identically everywhere it appears. Resolves the full record\n * from the ambient asset library (`useAssetRecord` — ProjectProviders already\n * mounts an `<AssetsProvider>` at the project level, EC-195, so every CMS list\n * surface sees it) and prefers it over the source's own summary because only the\n * ambient record carries the focal point. Falls back to the source's\n * `resolveAsset` label/best-effort-image (the AD-19 \"no `<AssetsProvider>`\"\n * degradation a portable rail — or a test double — without a mounted library\n * still needs) so this never regresses the prior behaviour. Never crashes on an\n * id that resolves to nothing — both paths render a placeholder, never a badge\n * defaulted from a missing record.\n */\nfunction AssetFieldThumb({\n assetId,\n assetCollectionId,\n}: {\n assetId: string\n assetCollectionId: string\n}) {\n const source = useCollectionView()\n const ambient = useAssetRecord(assetId)\n if (ambient) return <AssetThumb record={ambient} className=\"size-8 shrink-0\" />\n const resolved = source.resolveAsset?.(assetId)\n const label =\n resolved?.title ?? resolved?.filename ?? source.titleOf({ collection: assetCollectionId, id: assetId })\n return <AssetPreview record={resolved} label={label} />\n}\n\n/**\n * The pre-EC-346 fallback preview: an image thumbnail for image assets with a\n * resolvable URL (EC-212), otherwise the labelled icon badge (and the same\n * badge whenever the source can't resolve the record — e.g. no asset backend).\n * Used only when no ambient asset library is mounted (see {@link AssetFieldThumb}).\n */\nfunction AssetPreview({ record, label }: { record: ResolvedAsset | undefined; label: string }) {\n const isImage = record?.contentType?.startsWith('image/') ?? false\n const url = record?.url\n if (isImage && url && url.startsWith('http')) {\n return (\n <span className=\"inline-flex min-w-0 items-center gap-1.5\">\n <img\n src={url}\n alt={record?.alt ?? label}\n loading=\"lazy\"\n className=\"size-7 shrink-0 rounded border border-border bg-muted object-cover\"\n />\n <Text size=\"xs\" tone=\"subtle\" className=\"max-w-[10rem] truncate\">\n {label}\n </Text>\n </span>\n )\n }\n return (\n <Badge variant=\"outline\">\n <Image className=\"size-3\" /> {label}\n </Badge>\n )\n}\n\n/**\n * Normalize a relation value (single ref or array of refs) into an array of\n * `DocumentRef`s. EC-277: unwraps self-typed `{ type:'reference', ... }` envelopes\n * and legacy bare `{ collection, id }` via the shared tolerant reader.\n */\nexport function toRefArray(value: unknown): DocumentRef[] {\n const list = Array.isArray(value) ? value : [value]\n return list.map(readDocumentRef).filter((ref): ref is DocumentRef => ref !== undefined)\n}\n\nfunction stripHtml(html: string): string {\n return html.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n}\n\n/**\n * EC-348 — a richText field's stored value is the rich-text envelope\n * (`{ version, doc }` TipTap JSON, EC-314), not an HTML string: `stripHtml`\n * alone here yielded the literal `[object Object]`. Fallback chain: a valid\n * envelope extracts to plain text (`richTextPlainText`); a legacy plain/HTML\n * string (pre-EC-314 content, or any other non-envelope string) still goes\n * through `stripHtml` as before; anything else (garbage, wrong field-type data)\n * yields `''` so the cell's placeholder shows — never a crash, never\n * `[object Object]`.\n */\nfunction richTextCellSummary(value: unknown): string {\n const extracted = richTextPlainText(value)\n if (extracted !== undefined) return extracted\n return typeof value === 'string' ? stripHtml(value) : ''\n}\n"]}
@@ -1,10 +1,15 @@
1
1
  import type { CollectionDef } from '@elytracms/core/cms-core';
2
2
  import type { FilterClause, FilterableFieldOption } from '@elytracms/studio/lib/cms-views';
3
+ /** How a clause's value reads on the chip face — overridable to resolve relation titles. */
4
+ type DescribeValue = (clause: FilterClause, option: FilterableFieldOption | undefined) => string;
3
5
  /**
4
- * A one-line summary of the active filter for the chip face (EC-246b): the
5
- * single condition read out ("Status equals news"), or "N filters" for several.
6
+ * A one-line summary of the active filter for the chip face (EC-246b): the single
7
+ * condition read out ("Status is news", "Slug contains hello", "Author is Ada"), or
8
+ * "N filters" for several. Operator labels are type-aware and localized (EC-345);
9
+ * `empty` reads as just "… is empty" with no value; `describeValue` lets the caller
10
+ * swap raw scalars (e.g. a relation id) for a human title.
6
11
  */
7
- export declare function summarizeFilters(filters: readonly FilterClause[], options: readonly FilterableFieldOption[]): string;
12
+ export declare function summarizeFilters(filters: readonly FilterClause[], options: readonly FilterableFieldOption[], describeValue?: DescribeValue): string;
8
13
  /**
9
14
  * The collection filter entry (EC-246b) — a transient, ad-hoc refine that lives
10
15
  * beside the view tabs, not inline. With no active filter it's a compact
@@ -28,3 +33,4 @@ export declare function FilterControl({ collection }: {
28
33
  export declare function FilterBuilder({ collection }: {
29
34
  collection: CollectionDef;
30
35
  }): import("react").JSX.Element;
36
+ export {};
@@ -1,25 +1,33 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
3
  import { ListFilter, X } from 'lucide-react';
4
4
  import { Button, Popover, PopoverAnchor, PopoverContent, Row, Select, Spacer, Stack, Text, } from '@elytracms/studio/ui';
5
5
  import { cn } from '@elytracms/studio/lib/utils';
6
6
  import { translate } from '@elytracms/studio/lib/i18n';
7
- import { OPERATOR_LABELS, STATE_FIELD, defaultClause, filterableFieldOptions, operatorsFor, scalarListFromText, scalarListToText, validateClauses, } from '@elytracms/studio/lib/cms-views';
7
+ import { STATE_FIELD, defaultClause, filterableFieldOptions, operatorChoices, operatorLabelKey, scalarListFromText, scalarListToText, validateClauses, } from '@elytracms/studio/lib/cms-views';
8
8
  import { useCollectionView } from '../collection-view/collection-view-context';
9
+ import { RelationPicker } from './relation-picker';
9
10
  import { DraftValueInput, ScalarFieldValueEditor } from './scalar-value-editor';
11
+ const defaultDescribeValue = (clause) => Array.isArray(clause.value) ? clause.value.join(', ') : String(clause.value);
10
12
  /**
11
- * A one-line summary of the active filter for the chip face (EC-246b): the
12
- * single condition read out ("Status equals news"), or "N filters" for several.
13
+ * A one-line summary of the active filter for the chip face (EC-246b): the single
14
+ * condition read out ("Status is news", "Slug contains hello", "Author is Ada"), or
15
+ * "N filters" for several. Operator labels are type-aware and localized (EC-345);
16
+ * `empty` reads as just "… is empty" with no value; `describeValue` lets the caller
17
+ * swap raw scalars (e.g. a relation id) for a human title.
13
18
  */
14
- export function summarizeFilters(filters, options) {
19
+ export function summarizeFilters(filters, options, describeValue = defaultDescribeValue) {
15
20
  if (filters.length === 0)
16
21
  return translate('cms.filter.button');
17
22
  if (filters.length > 1)
18
23
  return translate('cms.filter.summaryMultiple', { count: filters.length });
19
24
  const clause = filters[0];
20
- const label = options.find((option) => option.field === clause.field)?.label ?? clause.field;
21
- const value = Array.isArray(clause.value) ? clause.value.join(', ') : String(clause.value);
22
- return `${label} ${OPERATOR_LABELS[clause.op]} ${value}`.trim();
25
+ const option = options.find((candidate) => candidate.field === clause.field);
26
+ const label = option?.label ?? clause.field;
27
+ const opLabel = translate(operatorLabelKey(clause.op, option, clause.value));
28
+ if (clause.op === 'empty')
29
+ return `${label} ${opLabel}`.trim();
30
+ return `${label} ${opLabel} ${describeValue(clause, option)}`.trim();
23
31
  }
24
32
  /**
25
33
  * The collection filter entry (EC-246b) — a transient, ad-hoc refine that lives
@@ -34,6 +42,18 @@ export function FilterControl({ collection }) {
34
42
  const filters = source.viewState().filters;
35
43
  const options = filterableFieldOptions(collection);
36
44
  const [open, setOpen] = useState(false);
45
+ // Relation filter values are target ids; read them out as titles on the chip.
46
+ const describeValue = (clause, option) => {
47
+ if (option?.def?.type === 'relation') {
48
+ const target = option.def.target;
49
+ const ids = Array.isArray(clause.value) ? clause.value : [clause.value];
50
+ const titles = ids
51
+ .filter((id) => id !== '' && id != null)
52
+ .map((id) => source.titleOf({ collection: target, id: String(id) }));
53
+ return titles.join(', ');
54
+ }
55
+ return defaultDescribeValue(clause, option);
56
+ };
37
57
  function addFirstClause() {
38
58
  source.setViewState((prev) => prev.filters.length > 0
39
59
  ? prev
@@ -44,7 +64,7 @@ export function FilterControl({ collection }) {
44
64
  source.setViewState((prev) => ({ ...prev, filters: [] }));
45
65
  setOpen(false);
46
66
  }
47
- return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverAnchor, { asChild: true, children: _jsx("div", { className: "inline-flex items-center", children: filters.length === 0 ? (_jsxs(Button, { variant: "ghost", size: "sm", "data-testid": "filter-entry", onClick: addFirstClause, children: [_jsx(ListFilter, {}), " ", translate('cms.filter.button')] })) : (_jsxs("div", { className: cn('inline-flex items-center gap-0.5 rounded-md border border-dashed border-primary/40', 'bg-primary/5 py-0.5 pl-2 pr-0.5 text-xs'), "data-testid": "filter-chip", children: [_jsx("button", { type: "button", className: "max-w-[220px] truncate text-foreground", onClick: () => setOpen(true), "data-testid": "filter-chip-edit", children: summarizeFilters(filters, options) }), _jsx("button", { type: "button", "aria-label": translate('cms.filter.clearAria'), className: "rounded-sm p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", onClick: clearAll, "data-testid": "filter-chip-clear", children: _jsx(X, { className: "size-3.5" }) })] })) }) }), _jsx(PopoverContent, { align: "start", className: "w-[min(92vw,440px)]", children: _jsxs(Stack, { gap: "2", children: [_jsxs(Row, { gap: "2", align: "center", children: [_jsx(Text, { as: "span", size: "xs", tone: "muted", className: "uppercase tracking-wide", children: translate('cms.filter.refineSession') }), _jsx(Spacer, {}), filters.length > 0 ? (_jsx(Button, { variant: "ghost", size: "sm", onClick: clearAll, "data-testid": "filter-clear-all", children: translate('cms.filter.clearAll') })) : null] }), _jsx(FilterBuilder, { collection: collection })] }) })] }));
67
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverAnchor, { asChild: true, children: _jsx("div", { className: "inline-flex items-center", children: filters.length === 0 ? (_jsxs(Button, { variant: "ghost", size: "sm", "data-testid": "filter-entry", onClick: addFirstClause, children: [_jsx(ListFilter, {}), " ", translate('cms.filter.button')] })) : (_jsxs("div", { className: cn('inline-flex items-center gap-0.5 rounded-md border border-dashed border-primary/40', 'bg-primary/5 py-0.5 pl-2 pr-0.5 text-xs'), "data-testid": "filter-chip", children: [_jsx("button", { type: "button", className: "max-w-[220px] truncate text-foreground", onClick: () => setOpen(true), "data-testid": "filter-chip-edit", children: summarizeFilters(filters, options, describeValue) }), _jsx("button", { type: "button", "aria-label": translate('cms.filter.clearAria'), className: "rounded-sm p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", onClick: clearAll, "data-testid": "filter-chip-clear", children: _jsx(X, { className: "size-3.5" }) })] })) }) }), _jsx(PopoverContent, { align: "start", className: "w-[min(92vw,440px)]", children: _jsxs(Stack, { gap: "2", children: [_jsxs(Row, { gap: "2", align: "center", children: [_jsx(Text, { as: "span", size: "xs", tone: "muted", className: "uppercase tracking-wide", children: translate('cms.filter.refineSession') }), _jsx(Spacer, {}), filters.length > 0 ? (_jsx(Button, { variant: "ghost", size: "sm", onClick: clearAll, "data-testid": "filter-clear-all", children: translate('cms.filter.clearAll') })) : null] }), _jsx(FilterBuilder, { collection: collection })] }) })] }));
48
68
  }
49
69
  /**
50
70
  * The filter builder of the collection table (EC-169). Strictly the closed
@@ -70,27 +90,53 @@ export function FilterBuilder({ collection }) {
70
90
  /** One AND-condition: field · operator · value, plus its validation issue. */
71
91
  function FilterClauseRow({ clause, index, options, issue, onChange, onRemove, }) {
72
92
  const option = options.find((candidate) => candidate.field === clause.field);
73
- const operators = option ? operatorsFor(option) : [clause.op];
93
+ const choices = option ? operatorChoices(option) : [];
94
+ const currentToken = tokenOfClause(clause);
74
95
  function changeField(field) {
75
96
  const next = options.find((candidate) => candidate.field === field);
76
97
  if (next)
77
98
  onChange(defaultClause(next));
78
99
  }
79
- function changeOperator(op) {
80
- if (op === 'in') {
81
- const list = Array.isArray(clause.value) ? clause.value : [clause.value];
82
- onChange({ ...clause, op, value: list.filter((value) => value !== '') });
83
- }
84
- else {
85
- const scalar = Array.isArray(clause.value) ? (clause.value[0] ?? '') : clause.value;
86
- onChange({ ...clause, op, value: scalar });
87
- }
100
+ function changeOperator(token) {
101
+ const choice = choices.find((candidate) => candidate.token === token);
102
+ if (!choice)
103
+ return;
104
+ onChange({ ...clause, op: choice.op, value: valueForChoice(choice, clause.value) });
105
+ }
106
+ return (_jsxs(Stack, { gap: "1", "data-testid": `filter-clause-${index}`, children: [_jsxs(Row, { gap: "1", className: "rounded-md border border-border bg-muted/30 p-1", "aria-invalid": issue ? true : undefined, children: [_jsxs(Select, { "aria-label": translate('cms.filter.fieldAria'), value: option ? clause.field : '', onChange: (event) => changeField(event.target.value), className: "h-7 w-auto py-0 text-xs", "data-testid": `filter-field-${index}`, children: [option ? null : (_jsx("option", { value: "", disabled: true, children: clause.field })), options.map((candidate) => (_jsx("option", { value: candidate.field, children: candidate.label }, candidate.field)))] }), _jsx(Select, { "aria-label": translate('cms.filter.operatorAria'), value: currentToken, onChange: (event) => changeOperator(event.target.value), className: "h-7 w-auto py-0 text-xs", "data-testid": `filter-op-${index}`, children: choices.length === 0 ? (_jsx("option", { value: currentToken, disabled: true, children: translate(operatorLabelKey(clause.op, option, clause.value)) })) : (choices.map((choice) => (_jsx("option", { value: choice.token, children: translate(choice.labelKey) }, choice.token)))) }), _jsx(ClauseValueEditor, { option: option, clause: clause, index: index, onChange: onChange }, `${clause.field}:${clause.op}`), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": translate('cms.filter.remove'), "data-testid": `filter-remove-${index}`, onClick: onRemove, children: _jsx(X, {}) })] }), issue ? (_jsx(Text, { size: "xs", className: "text-destructive", "data-testid": `filter-issue-${index}`, children: issue.message })) : null] }));
107
+ }
108
+ /** The `<select>` token for a clause's current operator (empty carries its true/false). */
109
+ function tokenOfClause(clause) {
110
+ if (clause.op === 'empty')
111
+ return clause.value === false ? 'empty:false' : 'empty:true';
112
+ return clause.op;
113
+ }
114
+ /** The value a clause takes when its operator changes to `choice` (EC-345). */
115
+ function valueForChoice(choice, current) {
116
+ if (choice.presetValue !== undefined)
117
+ return choice.presetValue; // empty → true/false
118
+ if (choice.op === 'in') {
119
+ const list = Array.isArray(current) ? current : [current];
120
+ return list.filter((value) => value !== '' && typeof value !== 'boolean');
88
121
  }
89
- return (_jsxs(Stack, { gap: "1", "data-testid": `filter-clause-${index}`, children: [_jsxs(Row, { gap: "1", className: "rounded-md border border-border bg-muted/30 p-1", "aria-invalid": issue ? true : undefined, children: [_jsxs(Select, { "aria-label": translate('cms.filter.fieldAria'), value: option ? clause.field : '', onChange: (event) => changeField(event.target.value), className: "h-7 w-auto py-0 text-xs", "data-testid": `filter-field-${index}`, children: [option ? null : (_jsx("option", { value: "", disabled: true, children: clause.field })), options.map((candidate) => (_jsx("option", { value: candidate.field, children: candidate.label }, candidate.field)))] }), _jsx(Select, { "aria-label": translate('cms.filter.operatorAria'), value: clause.op, onChange: (event) => changeOperator(event.target.value), className: "h-7 w-auto py-0 text-xs", "data-testid": `filter-op-${index}`, children: operators.map((op) => (_jsx("option", { value: op, children: OPERATOR_LABELS[op] }, op))) }), _jsx(ClauseValueEditor, { option: option, clause: clause, index: index, onChange: onChange }, `${clause.field}:${clause.op}`), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": translate('cms.filter.remove'), "data-testid": `filter-remove-${index}`, onClick: onRemove, children: _jsx(X, {}) })] }), issue ? (_jsx(Text, { size: "xs", className: "text-destructive", "data-testid": `filter-issue-${index}`, children: issue.message })) : null] }));
122
+ // A scalar operator: collapse an array to its first entry; drop a boolean left
123
+ // over from the `empty` operator so the reused value control starts clean.
124
+ const scalar = Array.isArray(current) ? (current[0] ?? '') : current;
125
+ return typeof scalar === 'boolean' ? '' : scalar;
90
126
  }
91
127
  /** The typed value input for a clause (closed per field type and operator). */
92
128
  function ClauseValueEditor({ option, clause, index, onChange, }) {
93
129
  const testId = `filter-value-${index}`;
130
+ // `empty` (is empty / is not empty) carries its verdict in the operator choice —
131
+ // there is no value to enter (EC-345).
132
+ if (clause.op === 'empty')
133
+ return null;
134
+ // A relation filter picks its target document(s) via the SAME relation picker the
135
+ // form/inline-cell use — ids are never typed (EC-345). `eq` (incl. membership on a
136
+ // `multiple` relation) picks one; `is any of` (`in`) picks several.
137
+ if (option?.def?.type === 'relation') {
138
+ return (_jsx(RelationClauseValue, { target: option.def.target, clause: clause, testId: testId, onChange: onChange }));
139
+ }
94
140
  if (clause.op === 'in') {
95
141
  return (_jsx(DraftValueInput, { ariaLabel: translate('cms.filter.valueAria'), initial: scalarListToText(clause.value), placeholder: translate('cms.filter.valueListPlaceholder'), testId: testId, onText: (raw) => onChange({ ...clause, value: scalarListFromText(option, raw) }) }));
96
142
  }
@@ -101,4 +147,25 @@ function ClauseValueEditor({ option, clause, index, onChange, }) {
101
147
  // Every concrete scalar field shares the bulk set-field control (EC-247).
102
148
  return (_jsx(ScalarFieldValueEditor, { option: option, value: scalar, ariaLabel: translate('cms.filter.valueAria'), testId: testId, onChange: (value) => onChange({ ...clause, value }) }));
103
149
  }
150
+ /**
151
+ * The relation filter value input (EC-345): a trigger showing the picked target
152
+ * title(s) that opens the existing {@link RelationPicker}. Ids are never typed. For
153
+ * `eq` (a single target, or membership on a `multiple` relation via array-contains,
154
+ * EC-255) it picks ONE; for `is any of` (`in`) it picks several. The stored value is
155
+ * the target id (a string for `eq`, an id array for `in`).
156
+ */
157
+ function RelationClauseValue({ target, clause, testId, onChange, }) {
158
+ const source = useCollectionView();
159
+ const [open, setOpen] = useState(false);
160
+ const multiPick = clause.op === 'in';
161
+ const ids = (Array.isArray(clause.value) ? clause.value : [clause.value])
162
+ .filter((value) => value !== '' && value != null)
163
+ .map((value) => String(value));
164
+ const refs = ids.map((id) => ({ collection: target, id }));
165
+ const titles = ids.map((id) => source.titleOf({ collection: target, id }));
166
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "outline", size: "sm", className: "h-7 max-w-[200px] truncate py-0 text-xs font-normal", "data-testid": testId, onClick: () => setOpen(true), children: titles.length > 0 ? titles.join(', ') : translate('cms.filter.pickRelation') }), _jsx(RelationPicker, { targetCollection: target, multiple: multiPick, value: refs, open: open, onOpenChange: setOpen, onChange: (next) => onChange({
167
+ ...clause,
168
+ value: multiPick ? next.map((ref) => ref.id) : (next[0]?.id ?? ''),
169
+ }) })] }));
170
+ }
104
171
  //# sourceMappingURL=filter-builder.js.map