@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 +135 -62
- package/convex/invites.ts +77 -0
- package/dist/components/cms/cell.js +18 -1
- package/dist/components/cms/cell.js.map +1 -1
- package/dist/components/cms/filter-builder.d.ts +9 -3
- package/dist/components/cms/filter-builder.js +87 -20
- package/dist/components/cms/filter-builder.js.map +1 -1
- package/dist/lib/auth/convex-auth-adapter.d.ts +51 -0
- package/dist/lib/auth/convex-auth-adapter.js +148 -67
- package/dist/lib/auth/convex-auth-adapter.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +56 -6
- package/dist/lib/cms-views/view-model.js +111 -23
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/i18n/messages.en.js +19 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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:
|
|
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":";AAAA,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,cAAc,CAAA;AAChE,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAA;AAEvE,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAA;AAC5D,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAClD,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AACtD,OAAO,EAAE,eAAe,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAA;AAGrF,OAAO,EAAE,iBAAiB,EAAE,MAAM,4CAA4C,CAAA;AAC9E,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAElE;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CAAC,EACxB,UAAU,EACV,GAAG,EACH,KAAK,GAKN;IACC,MAAM,MAAM,GAAG,iBAAiB,EAAE,CAAA;IAClC,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,qBAAqB,CAC/C,UAAU,EACV,GAAG,EACH,KAAK,CAAC,IAAI,EACV,MAAM,CAAC,YAAY,EACnB,MAAM,CAAC,YAAY,CACpB,CAAA;IAED,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,EAAE,EAAE,CAAC;QAC1D,OAAO,KAAC,KAAK,IAAC,SAAS,EAAC,mCAAmC,GAAG,CAAA;IAChE,CAAC;IAED,OAAO,CACL,gBAAM,SAAS,EAAC,qDAAqD,aACnE,KAAC,cAAc,IAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,GAAI,EAC9C,KAAC,YAAY,IAAC,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,iBAAiB,GAAG,CAAC,EAAE,IAAI,KAAK,CAAC,IAAI,EAAE,GAAI,IAChF,CACR,CAAA;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,YAAY,CAAC,EAC3B,QAAQ,EACR,MAAM,GAIP;IACC,IAAI,QAAQ,KAAK,IAAI;QAAE,OAAO,IAAI,CAAA;IAClC,OAAO,CACL,KAAC,KAAK,IACJ,OAAO,EAAC,SAAS,iBACJ,MAAM,EACnB,KAAK,EAAE,aAAa,CAAC,QAAQ,CAAC,EAC9B,SAAS,EAAC,gFAAgF,YAEzF,eAAe,CAAC,QAAQ,CAAC,YAAY,CAAC,GACjC,CACT,CAAA;AACH,CAAC;AAED,iDAAiD;AACjD,MAAM,UAAU,aAAa,CAAC,QAA2B;IACvD,OAAO,SAAS,CAAC,wBAAwB,EAAE;QACzC,MAAM,EAAE,eAAe,CAAC,QAAQ,CAAC,YAAY,CAAC;QAC9C,SAAS,EAAE,eAAe,CAAC,QAAQ,CAAC,eAAe,CAAC;KACrD,CAAC,CAAA;AACJ,CAAC;AAED,SAAS,cAAc,CAAC,EAAE,KAAK,EAAE,KAAK,EAAuC;IAC3E,QAAQ,KAAK,CAAC,IAAI,EAAE,CAAC;QACnB,KAAK,SAAS;YACZ,OAAO,KAAK,KAAK,IAAI,CAAC,CAAC,CAAC,CACtB,KAAC,KAAK,IAAC,SAAS,EAAC,qBAAqB,GAAG,CAC1C,CAAC,CAAC,CAAC,CACF,KAAC,KAAK,IAAC,SAAS,EAAC,mCAAmC,GAAG,CACxD,CAAA;QACH,KAAK,QAAQ;YACX,OAAO,KAAC,KAAK,IAAC,OAAO,EAAC,SAAS,YAAE,MAAM,CAAC,KAAK,CAAC,GAAS,CAAA;QACzD,KAAK,QAAQ;YACX,OAAO,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,kBAAE,MAAM,CAAC,KAAK,CAAC,GAAQ,CAAA;QACpD,KAAK,MAAM;YACT,OAAO,CACL,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,EAAC,IAAI,kBAC9B,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,GACtB,CACR,CAAA;QACH,KAAK,UAAU;YACb,OAAO,CACL,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,QAAQ,YAC1B,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,SAAS,CAAC,mBAAmB,CAAC,GACnE,CACR,CAAA;QACH,KAAK,UAAU;YACb,OAAO,KAAC,YAAY,IAAC,KAAK,EAAE,KAAK,GAAI,CAAA;QACvC,KAAK,OAAO;YACV,OAAO,KAAC,SAAS,IAAC,KAAK,EAAE,KAAK,GAAI,CAAA;QACpC,KAAK,QAAQ;YACX,OAAO,KAAC,UAAU,IAAC,KAAK,EAAE,KAAK,GAAI,CAAA;QACrC,KAAK,QAAQ,CAAC,CAAC,CAAC;YACd,2EAA2E;YAC3E,0EAA0E;YAC1E,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;gBACnB,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAA;gBACrD,OAAO,CACL,KAAC,KAAK,IAAC,OAAO,EAAC,SAAS,YACrB,SAAS,CAAC,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,wBAAwB,CAAC,CAAC,CAAC,0BAA0B,EAAE,EAAE,KAAK,EAAE,CAAC,GACpF,CACT,CAAA;YACH,CAAC;YACD,OAAO,CACL,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,QAAQ,YAC1B,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,GAAG,GACnE,CACR,CAAA;QACH,CAAC;QACD;YACE,OAAO,CACL,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,SAAS,EAAC,UAAU,YACjC,MAAM,CAAC,KAAK,CAAC,GACT,CACR,CAAA;IACL,CAAC;AACH,CAAC;AAED,SAAS,YAAY,CAAC,EAAE,KAAK,EAAsB;IACjD,MAAM,MAAM,GAAG,iBAAiB,EAAE,CAAA;IAClC,MAAM,IAAI,GAAG,UAAU,CAAC,KAAK,CAAC,CAAA;IAC9B,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAC,KAAK,IAAC,SAAS,EAAC,mCAAmC,GAAG,CAAA;IACrF,+EAA+E;IAC/E,gFAAgF;IAChF,sCAAsC;IACtC,OAAO,CACL,gBAAM,SAAS,EAAC,kDAAkD,aAChE,KAAC,KAAK,IAAC,SAAS,EAAC,yCAAyC,GAAG,EAC7D,eAAM,SAAS,EAAC,kBAAkB,YAC/B,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GACzD,EACN,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CACjB,MAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,QAAQ,EAAC,SAAS,EAAC,UAAU,kBAC9C,IAAI,CAAC,MAAM,GAAG,CAAC,IACZ,CACR,CAAC,CAAC,CAAC,IAAI,IACH,CACR,CAAA;AACH,CAAC;AAED;;;GAGG;AACH,SAAS,UAAU,CAAC,EAAE,KAAK,EAAsB;IAC/C,MAAM,KAAK,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC,MAAM,CAAA;IACxC,IAAI,KAAK,KAAK,CAAC;QAAE,OAAO,KAAC,KAAK,IAAC,SAAS,EAAC,mCAAmC,GAAG,CAAA;IAC/E,OAAO,CACL,MAAC,KAAK,IAAC,OAAO,EAAC,SAAS,aACtB,KAAC,KAAK,IAAC,SAAS,EAAC,QAAQ,GAAG,EAAC,GAAG,EAC/B,SAAS,CAAC,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,yBAAyB,CAAC,CAAC,CAAC,2BAA2B,EAAE,EAAE,KAAK,EAAE,CAAC,IACtF,CACT,CAAA;AACH,CAAC;AAED,SAAS,SAAS,CAAC,EAAE,KAAK,EAAsB;IAC9C,MAAM,MAAM,GAAG,iBAAiB,EAAE,CAAA;IAClC,MAAM,iBAAiB,GAAG,MAAM,CAAC,iBAAiB,IAAI,OAAO,CAAA;IAC7D,oFAAoF;IACpF,MAAM,GAAG,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;SACjD,GAAG,CAAC,WAAW,CAAC;SAChB,MAAM,CAAC,CAAC,EAAE,EAAgB,EAAE,CAAC,EAAE,KAAK,SAAS,CAAC,CAAA;IACjD,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAC,KAAK,IAAC,SAAS,EAAC,mCAAmC,GAAG,CAAA;IACpF,2EAA2E;IAC3E,gDAAgD;IAChD,MAAM,KAAK,GAAG,GAAG,CAAC,CAAC,CAAE,CAAA;IACrB,OAAO,CACL,eAAK,SAAS,EAAC,qCAAqC,aAClD,KAAC,eAAe,IAAC,OAAO,EAAE,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,GAAI,EACxE,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,MAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,QAAQ,kBAAG,GAAG,CAAC,MAAM,GAAG,CAAC,IAAQ,CAAC,CAAC,CAAC,IAAI,IAC3E,CACP,CAAA;AACH,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,SAAS,eAAe,CAAC,EACvB,OAAO,EACP,iBAAiB,GAIlB;IACC,MAAM,MAAM,GAAG,iBAAiB,EAAE,CAAA;IAClC,MAAM,OAAO,GAAG,cAAc,CAAC,OAAO,CAAC,CAAA;IACvC,IAAI,OAAO;QAAE,OAAO,KAAC,UAAU,IAAC,MAAM,EAAE,OAAO,EAAE,SAAS,EAAC,iBAAiB,GAAG,CAAA;IAC/E,MAAM,QAAQ,GAAG,MAAM,CAAC,YAAY,EAAE,CAAC,OAAO,CAAC,CAAA;IAC/C,MAAM,KAAK,GACT,QAAQ,EAAE,KAAK,IAAI,QAAQ,EAAE,QAAQ,IAAI,MAAM,CAAC,OAAO,CAAC,EAAE,UAAU,EAAE,iBAAiB,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC,CAAA;IACzG,OAAO,KAAC,YAAY,IAAC,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,GAAI,CAAA;AACzD,CAAC;AAED;;;;;GAKG;AACH,SAAS,YAAY,CAAC,EAAE,MAAM,EAAE,KAAK,EAAwD;IAC3F,MAAM,OAAO,GAAG,MAAM,EAAE,WAAW,EAAE,UAAU,CAAC,QAAQ,CAAC,IAAI,KAAK,CAAA;IAClE,MAAM,GAAG,GAAG,MAAM,EAAE,GAAG,CAAA;IACvB,IAAI,OAAO,IAAI,GAAG,IAAI,GAAG,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;QAC7C,OAAO,CACL,gBAAM,SAAS,EAAC,0CAA0C,aACxD,cACE,GAAG,EAAE,GAAG,EACR,GAAG,EAAE,MAAM,EAAE,GAAG,IAAI,KAAK,EACzB,OAAO,EAAC,MAAM,EACd,SAAS,EAAC,oEAAoE,GAC9E,EACF,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,QAAQ,EAAC,SAAS,EAAC,wBAAwB,YAC7D,KAAK,GACD,IACF,CACR,CAAA;IACH,CAAC;IACD,OAAO,CACL,MAAC,KAAK,IAAC,OAAO,EAAC,SAAS,aACtB,KAAC,KAAK,IAAC,SAAS,EAAC,QAAQ,GAAG,OAAE,KAAK,IAC7B,CACT,CAAA;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,UAAU,CAAC,KAAc;IACvC,MAAM,IAAI,GAAG,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAA;IACnD,OAAO,IAAI,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAsB,EAAE,CAAC,GAAG,KAAK,SAAS,CAAC,CAAA;AACzF,CAAC;AAED,SAAS,SAAS,CAAC,IAAY;IAC7B,OAAO,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAA;AAClE,CAAC","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
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
21
|
-
const
|
|
22
|
-
|
|
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
|
|
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(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|