@elytracms/studio 0.0.50 → 0.0.51

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.
@@ -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
@@ -1 +1 @@
1
- {"version":3,"file":"filter-builder.js","sourceRoot":"","sources":["../../../src/components/cms/filter-builder.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useState } from 'react'\nimport { ListFilter, X } from 'lucide-react'\nimport type { CollectionDef } from '@elytracms/core/cms-core'\nimport type { FilterOperator } from '@elytracms/core/content'\nimport {\n Button,\n Popover,\n PopoverAnchor,\n PopoverContent,\n Row,\n Select,\n Spacer,\n Stack,\n Text,\n} from '@elytracms/studio/ui'\nimport { cn } from '@elytracms/studio/lib/utils'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport {\n OPERATOR_LABELS,\n STATE_FIELD,\n defaultClause,\n filterableFieldOptions,\n operatorsFor,\n scalarListFromText,\n scalarListToText,\n validateClauses,\n} from '@elytracms/studio/lib/cms-views'\nimport type { ClauseIssue, FilterClause, FilterableFieldOption } from '@elytracms/studio/lib/cms-views'\nimport { useCollectionView } from '../collection-view/collection-view-context'\nimport { DraftValueInput, ScalarFieldValueEditor } from './scalar-value-editor'\n\n/**\n * A one-line summary of the active filter for the chip face (EC-246b): the\n * single condition read out (\"Status equals news\"), or \"N filters\" for several.\n */\nexport function summarizeFilters(\n filters: readonly FilterClause[],\n options: readonly FilterableFieldOption[],\n): string {\n if (filters.length === 0) return translate('cms.filter.button')\n if (filters.length > 1) return translate('cms.filter.summaryMultiple', { count: filters.length })\n const clause = filters[0]!\n const label = options.find((option) => option.field === clause.field)?.label ?? clause.field\n const value = Array.isArray(clause.value) ? clause.value.join(', ') : String(clause.value)\n return `${label} ${OPERATOR_LABELS[clause.op]} ${value}`.trim()\n}\n\n/**\n * The collection filter entry (EC-246b) — a transient, ad-hoc refine that lives\n * beside the view tabs, not inline. With no active filter it's a compact\n * \"Filter\" button; once refining it's a dashed accent **chip** (distinct from\n * the solid view tabs — a filter is a session lens, not a saved one) whose face\n * opens the condition editor in a **popover** (the table stays visible and\n * updates live) and whose ✕ clears it. Shared/saved views are config-as-code.\n */\nexport function FilterControl({ collection }: { collection: CollectionDef }) {\n const source = useCollectionView()\n const filters = source.viewState().filters\n const options = filterableFieldOptions(collection)\n const [open, setOpen] = useState(false)\n\n function addFirstClause() {\n source.setViewState((prev) =>\n prev.filters.length > 0\n ? prev\n : { ...prev, filters: [...prev.filters, defaultClause(options[0]!)] },\n )\n setOpen(true)\n }\n\n function clearAll() {\n source.setViewState((prev) => ({ ...prev, filters: [] }))\n setOpen(false)\n }\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n {/* Stable anchor so positioning doesn't jump as the trigger morphs button↔chip. */}\n <PopoverAnchor asChild>\n <div className=\"inline-flex items-center\">\n {filters.length === 0 ? (\n <Button variant=\"ghost\" size=\"sm\" data-testid=\"filter-entry\" onClick={addFirstClause}>\n <ListFilter /> {translate('cms.filter.button')}\n </Button>\n ) : (\n <div\n className={cn(\n 'inline-flex items-center gap-0.5 rounded-md border border-dashed border-primary/40',\n 'bg-primary/5 py-0.5 pl-2 pr-0.5 text-xs',\n )}\n data-testid=\"filter-chip\"\n >\n <button\n type=\"button\"\n className=\"max-w-[220px] truncate text-foreground\"\n onClick={() => setOpen(true)}\n data-testid=\"filter-chip-edit\"\n >\n {summarizeFilters(filters, options)}\n </button>\n <button\n type=\"button\"\n aria-label={translate('cms.filter.clearAria')}\n className=\"rounded-sm p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n onClick={clearAll}\n data-testid=\"filter-chip-clear\"\n >\n <X className=\"size-3.5\" />\n </button>\n </div>\n )}\n </div>\n </PopoverAnchor>\n <PopoverContent align=\"start\" className=\"w-[min(92vw,440px)]\">\n <Stack gap=\"2\">\n <Row gap=\"2\" align=\"center\">\n <Text as=\"span\" size=\"xs\" tone=\"muted\" className=\"uppercase tracking-wide\">\n {translate('cms.filter.refineSession')}\n </Text>\n <Spacer />\n {filters.length > 0 ? (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={clearAll}\n data-testid=\"filter-clear-all\"\n >\n {translate('cms.filter.clearAll')}\n </Button>\n ) : null}\n </Row>\n <FilterBuilder collection={collection} />\n </Stack>\n </PopoverContent>\n </Popover>\n )\n}\n\n/**\n * The filter builder of the collection table (EC-169). Strictly the closed\n * AD-3 vocabulary: it offers ONLY schema-`filterable` fields (plus the\n * built-in publication state), only `eq`/`in`/`lt`/`lte`/`gt`/`gte`, and\n * conditions combine with AND — there is no OR, no nesting, no free-text\n * query input. Clauses that no longer validate (e.g. a saved view whose field\n * lost its `filterable` flag) are flagged inline and excluded from matching.\n * Rendered inside {@link FilterControl}'s popover (EC-246b).\n */\nexport function FilterBuilder({ collection }: { collection: CollectionDef }) {\n const source = useCollectionView()\n const state = source.viewState()\n const options = filterableFieldOptions(collection)\n const issues = validateClauses(collection, state.filters)\n\n // Always update through the previous state — render snapshots go stale\n // across rapid successive events (sort clicks, column toggles).\n function updateFilters(update: (filters: FilterClause[]) => FilterClause[]) {\n source.setViewState((prev) => ({ ...prev, filters: update(prev.filters) }))\n }\n\n return (\n <Row gap=\"2\" wrap align=\"start\" data-testid=\"filter-builder\">\n {state.filters.map((clause, index) => (\n <FilterClauseRow\n key={index}\n clause={clause}\n index={index}\n options={options}\n issue={issues.find((issue) => issue.index === index) ?? null}\n onChange={(next) => updateFilters((filters) => filters.map((c, i) => (i === index ? next : c)))}\n onRemove={() => updateFilters((filters) => filters.filter((_, i) => i !== index))}\n />\n ))}\n <Button\n variant=\"ghost\"\n size=\"sm\"\n data-testid=\"add-filter\"\n onClick={() => updateFilters((filters) => [...filters, defaultClause(options[0]!)])}\n >\n <ListFilter /> {translate('cms.filter.add')}\n </Button>\n {state.filters.length > 1 ? (\n <Text as=\"span\" size=\"xs\" tone=\"subtle\" className=\"self-center\">\n {translate('cms.filter.andHint')}\n </Text>\n ) : null}\n </Row>\n )\n}\n\n/** One AND-condition: field · operator · value, plus its validation issue. */\nfunction FilterClauseRow({\n clause,\n index,\n options,\n issue,\n onChange,\n onRemove,\n}: {\n clause: FilterClause\n index: number\n options: FilterableFieldOption[]\n issue: ClauseIssue | null\n onChange: (clause: FilterClause) => void\n onRemove: () => void\n}) {\n const option = options.find((candidate) => candidate.field === clause.field)\n const operators = option ? operatorsFor(option) : [clause.op]\n\n function changeField(field: string) {\n const next = options.find((candidate) => candidate.field === field)\n if (next) onChange(defaultClause(next))\n }\n\n function changeOperator(op: FilterOperator) {\n if (op === 'in') {\n const list = Array.isArray(clause.value) ? clause.value : [clause.value]\n onChange({ ...clause, op, value: list.filter((value) => value !== '') })\n } else {\n const scalar = Array.isArray(clause.value) ? (clause.value[0] ?? '') : clause.value\n onChange({ ...clause, op, value: scalar })\n }\n }\n\n return (\n <Stack gap=\"1\" data-testid={`filter-clause-${index}`}>\n <Row\n gap=\"1\"\n className=\"rounded-md border border-border bg-muted/30 p-1\"\n aria-invalid={issue ? true : undefined}\n >\n <Select\n aria-label={translate('cms.filter.fieldAria')}\n value={option ? clause.field : ''}\n onChange={(event) => changeField(event.target.value)}\n className=\"h-7 w-auto py-0 text-xs\"\n data-testid={`filter-field-${index}`}\n >\n {option ? null : (\n <option value=\"\" disabled>\n {clause.field}\n </option>\n )}\n {options.map((candidate) => (\n <option key={candidate.field} value={candidate.field}>\n {candidate.label}\n </option>\n ))}\n </Select>\n <Select\n aria-label={translate('cms.filter.operatorAria')}\n value={clause.op}\n onChange={(event) => changeOperator(event.target.value as FilterOperator)}\n className=\"h-7 w-auto py-0 text-xs\"\n data-testid={`filter-op-${index}`}\n >\n {operators.map((op) => (\n <option key={op} value={op}>\n {OPERATOR_LABELS[op]}\n </option>\n ))}\n </Select>\n <ClauseValueEditor\n key={`${clause.field}:${clause.op}`}\n option={option}\n clause={clause}\n index={index}\n onChange={onChange}\n />\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={translate('cms.filter.remove')}\n data-testid={`filter-remove-${index}`}\n onClick={onRemove}\n >\n <X />\n </Button>\n </Row>\n {issue ? (\n <Text size=\"xs\" className=\"text-destructive\" data-testid={`filter-issue-${index}`}>\n {issue.message}\n </Text>\n ) : null}\n </Stack>\n )\n}\n\n/** The typed value input for a clause (closed per field type and operator). */\nfunction ClauseValueEditor({\n option,\n clause,\n index,\n onChange,\n}: {\n option: FilterableFieldOption | undefined\n clause: FilterClause\n index: number\n onChange: (clause: FilterClause) => void\n}) {\n const testId = `filter-value-${index}`\n\n if (clause.op === 'in') {\n return (\n <DraftValueInput\n ariaLabel={translate('cms.filter.valueAria')}\n initial={scalarListToText(clause.value)}\n placeholder={translate('cms.filter.valueListPlaceholder')}\n testId={testId}\n onText={(raw) => onChange({ ...clause, value: scalarListFromText(option, raw) })}\n />\n )\n }\n\n const scalar = Array.isArray(clause.value) ? (clause.value[0] ?? '') : clause.value\n\n if (clause.field === STATE_FIELD || option?.def === null) {\n return (\n <Select\n aria-label={translate('cms.filter.valueAria')}\n value={String(scalar)}\n onChange={(event) => onChange({ ...clause, value: event.target.value })}\n className=\"h-7 w-auto py-0 text-xs\"\n data-testid={testId}\n >\n <option value=\"published\">{translate('cms.filter.state.published')}</option>\n <option value=\"draft\">{translate('cms.filter.state.draft')}</option>\n </Select>\n )\n }\n\n // Every concrete scalar field shares the bulk set-field control (EC-247).\n return (\n <ScalarFieldValueEditor\n option={option}\n value={scalar}\n ariaLabel={translate('cms.filter.valueAria')}\n testId={testId}\n onChange={(value) => onChange({ ...clause, value })}\n />\n )\n}\n"]}
1
+ {"version":3,"file":"filter-builder.js","sourceRoot":"","sources":["../../../src/components/cms/filter-builder.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useState } from 'react'\nimport { ListFilter, X } from 'lucide-react'\nimport type { CollectionDef, DocumentRef } from '@elytracms/core/cms-core'\nimport type { FilterScalar } from '@elytracms/core/content'\nimport {\n Button,\n Popover,\n PopoverAnchor,\n PopoverContent,\n Row,\n Select,\n Spacer,\n Stack,\n Text,\n} from '@elytracms/studio/ui'\nimport { cn } from '@elytracms/studio/lib/utils'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport {\n STATE_FIELD,\n defaultClause,\n filterableFieldOptions,\n operatorChoices,\n operatorLabelKey,\n scalarListFromText,\n scalarListToText,\n validateClauses,\n} from '@elytracms/studio/lib/cms-views'\nimport type {\n ClauseIssue,\n FilterClause,\n FilterableFieldOption,\n OperatorChoice,\n} from '@elytracms/studio/lib/cms-views'\nimport { useCollectionView } from '../collection-view/collection-view-context'\nimport { RelationPicker } from './relation-picker'\nimport { DraftValueInput, ScalarFieldValueEditor } from './scalar-value-editor'\n\n/** How a clause's value reads on the chip face — overridable to resolve relation titles. */\ntype DescribeValue = (clause: FilterClause, option: FilterableFieldOption | undefined) => string\n\nconst defaultDescribeValue: DescribeValue = (clause) =>\n Array.isArray(clause.value) ? clause.value.join(', ') : String(clause.value)\n\n/**\n * A one-line summary of the active filter for the chip face (EC-246b): the single\n * condition read out (\"Status is news\", \"Slug contains hello\", \"Author is Ada\"), or\n * \"N filters\" for several. Operator labels are type-aware and localized (EC-345);\n * `empty` reads as just \"… is empty\" with no value; `describeValue` lets the caller\n * swap raw scalars (e.g. a relation id) for a human title.\n */\nexport function summarizeFilters(\n filters: readonly FilterClause[],\n options: readonly FilterableFieldOption[],\n describeValue: DescribeValue = defaultDescribeValue,\n): string {\n if (filters.length === 0) return translate('cms.filter.button')\n if (filters.length > 1) return translate('cms.filter.summaryMultiple', { count: filters.length })\n const clause = filters[0]!\n const option = options.find((candidate) => candidate.field === clause.field)\n const label = option?.label ?? clause.field\n const opLabel = translate(operatorLabelKey(clause.op, option, clause.value))\n if (clause.op === 'empty') return `${label} ${opLabel}`.trim()\n return `${label} ${opLabel} ${describeValue(clause, option)}`.trim()\n}\n\n/**\n * The collection filter entry (EC-246b) — a transient, ad-hoc refine that lives\n * beside the view tabs, not inline. With no active filter it's a compact\n * \"Filter\" button; once refining it's a dashed accent **chip** (distinct from\n * the solid view tabs — a filter is a session lens, not a saved one) whose face\n * opens the condition editor in a **popover** (the table stays visible and\n * updates live) and whose ✕ clears it. Shared/saved views are config-as-code.\n */\nexport function FilterControl({ collection }: { collection: CollectionDef }) {\n const source = useCollectionView()\n const filters = source.viewState().filters\n const options = filterableFieldOptions(collection)\n const [open, setOpen] = useState(false)\n\n // Relation filter values are target ids; read them out as titles on the chip.\n const describeValue: DescribeValue = (clause, option) => {\n if (option?.def?.type === 'relation') {\n const target = option.def.target\n const ids = Array.isArray(clause.value) ? clause.value : [clause.value]\n const titles = ids\n .filter((id) => id !== '' && id != null)\n .map((id) => source.titleOf({ collection: target, id: String(id) }))\n return titles.join(', ')\n }\n return defaultDescribeValue(clause, option)\n }\n\n function addFirstClause() {\n source.setViewState((prev) =>\n prev.filters.length > 0\n ? prev\n : { ...prev, filters: [...prev.filters, defaultClause(options[0]!)] },\n )\n setOpen(true)\n }\n\n function clearAll() {\n source.setViewState((prev) => ({ ...prev, filters: [] }))\n setOpen(false)\n }\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n {/* Stable anchor so positioning doesn't jump as the trigger morphs button↔chip. */}\n <PopoverAnchor asChild>\n <div className=\"inline-flex items-center\">\n {filters.length === 0 ? (\n <Button variant=\"ghost\" size=\"sm\" data-testid=\"filter-entry\" onClick={addFirstClause}>\n <ListFilter /> {translate('cms.filter.button')}\n </Button>\n ) : (\n <div\n className={cn(\n 'inline-flex items-center gap-0.5 rounded-md border border-dashed border-primary/40',\n 'bg-primary/5 py-0.5 pl-2 pr-0.5 text-xs',\n )}\n data-testid=\"filter-chip\"\n >\n <button\n type=\"button\"\n className=\"max-w-[220px] truncate text-foreground\"\n onClick={() => setOpen(true)}\n data-testid=\"filter-chip-edit\"\n >\n {summarizeFilters(filters, options, describeValue)}\n </button>\n <button\n type=\"button\"\n aria-label={translate('cms.filter.clearAria')}\n className=\"rounded-sm p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n onClick={clearAll}\n data-testid=\"filter-chip-clear\"\n >\n <X className=\"size-3.5\" />\n </button>\n </div>\n )}\n </div>\n </PopoverAnchor>\n <PopoverContent align=\"start\" className=\"w-[min(92vw,440px)]\">\n <Stack gap=\"2\">\n <Row gap=\"2\" align=\"center\">\n <Text as=\"span\" size=\"xs\" tone=\"muted\" className=\"uppercase tracking-wide\">\n {translate('cms.filter.refineSession')}\n </Text>\n <Spacer />\n {filters.length > 0 ? (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={clearAll}\n data-testid=\"filter-clear-all\"\n >\n {translate('cms.filter.clearAll')}\n </Button>\n ) : null}\n </Row>\n <FilterBuilder collection={collection} />\n </Stack>\n </PopoverContent>\n </Popover>\n )\n}\n\n/**\n * The filter builder of the collection table (EC-169). Strictly the closed\n * AD-3 vocabulary: it offers ONLY schema-`filterable` fields (plus the\n * built-in publication state), only `eq`/`in`/`lt`/`lte`/`gt`/`gte`, and\n * conditions combine with AND — there is no OR, no nesting, no free-text\n * query input. Clauses that no longer validate (e.g. a saved view whose field\n * lost its `filterable` flag) are flagged inline and excluded from matching.\n * Rendered inside {@link FilterControl}'s popover (EC-246b).\n */\nexport function FilterBuilder({ collection }: { collection: CollectionDef }) {\n const source = useCollectionView()\n const state = source.viewState()\n const options = filterableFieldOptions(collection)\n const issues = validateClauses(collection, state.filters)\n\n // Always update through the previous state — render snapshots go stale\n // across rapid successive events (sort clicks, column toggles).\n function updateFilters(update: (filters: FilterClause[]) => FilterClause[]) {\n source.setViewState((prev) => ({ ...prev, filters: update(prev.filters) }))\n }\n\n return (\n <Row gap=\"2\" wrap align=\"start\" data-testid=\"filter-builder\">\n {state.filters.map((clause, index) => (\n <FilterClauseRow\n key={index}\n clause={clause}\n index={index}\n options={options}\n issue={issues.find((issue) => issue.index === index) ?? null}\n onChange={(next) => updateFilters((filters) => filters.map((c, i) => (i === index ? next : c)))}\n onRemove={() => updateFilters((filters) => filters.filter((_, i) => i !== index))}\n />\n ))}\n <Button\n variant=\"ghost\"\n size=\"sm\"\n data-testid=\"add-filter\"\n onClick={() => updateFilters((filters) => [...filters, defaultClause(options[0]!)])}\n >\n <ListFilter /> {translate('cms.filter.add')}\n </Button>\n {state.filters.length > 1 ? (\n <Text as=\"span\" size=\"xs\" tone=\"subtle\" className=\"self-center\">\n {translate('cms.filter.andHint')}\n </Text>\n ) : null}\n </Row>\n )\n}\n\n/** One AND-condition: field · operator · value, plus its validation issue. */\nfunction FilterClauseRow({\n clause,\n index,\n options,\n issue,\n onChange,\n onRemove,\n}: {\n clause: FilterClause\n index: number\n options: FilterableFieldOption[]\n issue: ClauseIssue | null\n onChange: (clause: FilterClause) => void\n onRemove: () => void\n}) {\n const option = options.find((candidate) => candidate.field === clause.field)\n const choices = option ? operatorChoices(option) : []\n const currentToken = tokenOfClause(clause)\n\n function changeField(field: string) {\n const next = options.find((candidate) => candidate.field === field)\n if (next) onChange(defaultClause(next))\n }\n\n function changeOperator(token: string) {\n const choice = choices.find((candidate) => candidate.token === token)\n if (!choice) return\n onChange({ ...clause, op: choice.op, value: valueForChoice(choice, clause.value) })\n }\n\n return (\n <Stack gap=\"1\" data-testid={`filter-clause-${index}`}>\n <Row\n gap=\"1\"\n className=\"rounded-md border border-border bg-muted/30 p-1\"\n aria-invalid={issue ? true : undefined}\n >\n <Select\n aria-label={translate('cms.filter.fieldAria')}\n value={option ? clause.field : ''}\n onChange={(event) => changeField(event.target.value)}\n className=\"h-7 w-auto py-0 text-xs\"\n data-testid={`filter-field-${index}`}\n >\n {option ? null : (\n <option value=\"\" disabled>\n {clause.field}\n </option>\n )}\n {options.map((candidate) => (\n <option key={candidate.field} value={candidate.field}>\n {candidate.label}\n </option>\n ))}\n </Select>\n <Select\n aria-label={translate('cms.filter.operatorAria')}\n value={currentToken}\n onChange={(event) => changeOperator(event.target.value)}\n className=\"h-7 w-auto py-0 text-xs\"\n data-testid={`filter-op-${index}`}\n >\n {choices.length === 0 ? (\n <option value={currentToken} disabled>\n {translate(operatorLabelKey(clause.op, option, clause.value))}\n </option>\n ) : (\n choices.map((choice) => (\n <option key={choice.token} value={choice.token}>\n {translate(choice.labelKey)}\n </option>\n ))\n )}\n </Select>\n <ClauseValueEditor\n key={`${clause.field}:${clause.op}`}\n option={option}\n clause={clause}\n index={index}\n onChange={onChange}\n />\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={translate('cms.filter.remove')}\n data-testid={`filter-remove-${index}`}\n onClick={onRemove}\n >\n <X />\n </Button>\n </Row>\n {issue ? (\n <Text size=\"xs\" className=\"text-destructive\" data-testid={`filter-issue-${index}`}>\n {issue.message}\n </Text>\n ) : null}\n </Stack>\n )\n}\n\n/** The `<select>` token for a clause's current operator (empty carries its true/false). */\nfunction tokenOfClause(clause: FilterClause): string {\n if (clause.op === 'empty') return clause.value === false ? 'empty:false' : 'empty:true'\n return clause.op\n}\n\n/** The value a clause takes when its operator changes to `choice` (EC-345). */\nfunction valueForChoice(\n choice: OperatorChoice,\n current: FilterScalar | FilterScalar[],\n): FilterScalar | FilterScalar[] {\n if (choice.presetValue !== undefined) return choice.presetValue // empty → true/false\n if (choice.op === 'in') {\n const list = Array.isArray(current) ? current : [current]\n return list.filter((value) => value !== '' && typeof value !== 'boolean')\n }\n // A scalar operator: collapse an array to its first entry; drop a boolean left\n // over from the `empty` operator so the reused value control starts clean.\n const scalar = Array.isArray(current) ? (current[0] ?? '') : current\n return typeof scalar === 'boolean' ? '' : scalar\n}\n\n/** The typed value input for a clause (closed per field type and operator). */\nfunction ClauseValueEditor({\n option,\n clause,\n index,\n onChange,\n}: {\n option: FilterableFieldOption | undefined\n clause: FilterClause\n index: number\n onChange: (clause: FilterClause) => void\n}) {\n const testId = `filter-value-${index}`\n\n // `empty` (is empty / is not empty) carries its verdict in the operator choice —\n // there is no value to enter (EC-345).\n if (clause.op === 'empty') return null\n\n // A relation filter picks its target document(s) via the SAME relation picker the\n // form/inline-cell use — ids are never typed (EC-345). `eq` (incl. membership on a\n // `multiple` relation) picks one; `is any of` (`in`) picks several.\n if (option?.def?.type === 'relation') {\n return (\n <RelationClauseValue\n target={option.def.target}\n clause={clause}\n testId={testId}\n onChange={onChange}\n />\n )\n }\n\n if (clause.op === 'in') {\n return (\n <DraftValueInput\n ariaLabel={translate('cms.filter.valueAria')}\n initial={scalarListToText(clause.value)}\n placeholder={translate('cms.filter.valueListPlaceholder')}\n testId={testId}\n onText={(raw) => onChange({ ...clause, value: scalarListFromText(option, raw) })}\n />\n )\n }\n\n const scalar = Array.isArray(clause.value) ? (clause.value[0] ?? '') : clause.value\n\n if (clause.field === STATE_FIELD || option?.def === null) {\n return (\n <Select\n aria-label={translate('cms.filter.valueAria')}\n value={String(scalar)}\n onChange={(event) => onChange({ ...clause, value: event.target.value })}\n className=\"h-7 w-auto py-0 text-xs\"\n data-testid={testId}\n >\n <option value=\"published\">{translate('cms.filter.state.published')}</option>\n <option value=\"draft\">{translate('cms.filter.state.draft')}</option>\n </Select>\n )\n }\n\n // Every concrete scalar field shares the bulk set-field control (EC-247).\n return (\n <ScalarFieldValueEditor\n option={option}\n value={scalar}\n ariaLabel={translate('cms.filter.valueAria')}\n testId={testId}\n onChange={(value) => onChange({ ...clause, value })}\n />\n )\n}\n\n/**\n * The relation filter value input (EC-345): a trigger showing the picked target\n * title(s) that opens the existing {@link RelationPicker}. Ids are never typed. For\n * `eq` (a single target, or membership on a `multiple` relation via array-contains,\n * EC-255) it picks ONE; for `is any of` (`in`) it picks several. The stored value is\n * the target id (a string for `eq`, an id array for `in`).\n */\nfunction RelationClauseValue({\n target,\n clause,\n testId,\n onChange,\n}: {\n target: string\n clause: FilterClause\n testId: string\n onChange: (clause: FilterClause) => void\n}) {\n const source = useCollectionView()\n const [open, setOpen] = useState(false)\n const multiPick = clause.op === 'in'\n const ids = (Array.isArray(clause.value) ? clause.value : [clause.value])\n .filter((value) => value !== '' && value != null)\n .map((value) => String(value))\n const refs: DocumentRef[] = ids.map((id) => ({ collection: target, id }))\n const titles = ids.map((id) => source.titleOf({ collection: target, id }))\n\n return (\n <>\n <Button\n variant=\"outline\"\n size=\"sm\"\n className=\"h-7 max-w-[200px] truncate py-0 text-xs font-normal\"\n data-testid={testId}\n onClick={() => setOpen(true)}\n >\n {titles.length > 0 ? titles.join(', ') : translate('cms.filter.pickRelation')}\n </Button>\n <RelationPicker\n targetCollection={target}\n multiple={multiPick}\n value={refs}\n open={open}\n onOpenChange={setOpen}\n onChange={(next) =>\n onChange({\n ...clause,\n value: multiPick ? next.map((ref) => ref.id) : (next[0]?.id ?? ''),\n })\n }\n />\n </>\n )\n}\n"]}
@@ -21,7 +21,11 @@ export declare const filterScalarSchema: z.ZodUnion<readonly [z.ZodString, z.Zod
21
21
  export declare const filterClauseSchema: z.ZodObject<{
22
22
  field: z.ZodString;
23
23
  op: z.ZodEnum<{
24
+ empty: "empty";
25
+ contains: "contains";
24
26
  in: "in";
27
+ endsWith: "endsWith";
28
+ startsWith: "startsWith";
25
29
  eq: "eq";
26
30
  gt: "gt";
27
31
  gte: "gte";
@@ -43,7 +47,11 @@ export declare const tableViewStateSchema: z.ZodObject<{
43
47
  filters: z.ZodArray<z.ZodObject<{
44
48
  field: z.ZodString;
45
49
  op: z.ZodEnum<{
50
+ empty: "empty";
51
+ contains: "contains";
46
52
  in: "in";
53
+ endsWith: "endsWith";
54
+ startsWith: "startsWith";
47
55
  eq: "eq";
48
56
  gt: "gt";
49
57
  gte: "gte";
@@ -84,14 +92,47 @@ export declare const STATE_OPTION: FilterableFieldOption;
84
92
  */
85
93
  export declare function filterableFieldOptions(collection: CollectionDef): FilterableFieldOption[];
86
94
  export declare function filterOptionFor(collection: CollectionDef, field: string): FilterableFieldOption | undefined;
87
- /** Display order + labels for the closed vocabulary. */
88
- export declare const OPERATOR_LABELS: Record<FilterOperator, string>;
89
95
  /**
90
- * The vocabulary subset that makes sense for a field's type. Always a subset
91
- * of the closed `FILTER_OPERATORS` — never an extension.
96
+ * The per-type operator SET the builder offers (EC-345), always a subset of the
97
+ * closed `FILTER_OPERATORS` — the UI narrows the vocabulary, it never extends it.
98
+ * Ratified sets (Oli, 2026-07-08):
99
+ *
100
+ * - text: contains (default) · equals · starts with · ends with · empty
101
+ * - number: equals · < · ≤ · > · ≥ · empty
102
+ * - date: equals · before · … · after · empty (same ops as number, date labels)
103
+ * - boolean: is
104
+ * - select: is · is any of · empty
105
+ * - relation / asset: is · is any of · empty (relation value = the relation picker;
106
+ * `eq` on a `multiple` relation is membership — array-contains, EC-255 semantics)
107
+ * - state pseudo-field: is · is any of (unchanged — a publication state is never empty)
92
108
  */
93
109
  export declare function operatorsFor(option: FilterableFieldOption): FilterOperator[];
94
- /** A sensible starting clause when a field is added to the builder. */
110
+ /**
111
+ * A visual choice in the operator dropdown (EC-345). Usually one per operator,
112
+ * but `empty` splits into two — "is empty" (`true`) and "is not empty" (`false`) —
113
+ * so an editor never has to reason about a boolean value; the choice carries the
114
+ * preset. `token` is the `<option value>`, `labelKey` a catalog key ({@link operatorLabelKey}).
115
+ */
116
+ export interface OperatorChoice {
117
+ token: string;
118
+ op: FilterOperator;
119
+ /** Value forced when this choice is picked (the `empty` true/false), else undefined. */
120
+ presetValue?: FilterScalar;
121
+ labelKey: string;
122
+ }
123
+ /**
124
+ * The operator dropdown entries for a field (EC-345): {@link operatorsFor} expanded
125
+ * for display, with `empty` shown as the two self-describing choices.
126
+ */
127
+ export declare function operatorChoices(option: FilterableFieldOption): OperatorChoice[];
128
+ /**
129
+ * The catalog key for an operator's label (EC-241/EC-345), keyed by operator AND
130
+ * field type — `eq` reads "equals" on text/number, "on" on a date, "is" on an
131
+ * enum-like field; `lt` reads "less than" on a number, "before" on a date. `empty`
132
+ * additionally depends on its VALUE (is empty / is not empty). Pure → unit-tested.
133
+ */
134
+ export declare function operatorLabelKey(op: FilterOperator, option: FilterableFieldOption | undefined, value?: FilterScalar | readonly FilterScalar[]): string;
135
+ /** A sensible starting clause when a field is added to the builder (EC-345). */
95
136
  export declare function defaultClause(option: FilterableFieldOption): FilterClause;
96
137
  /** Coerce one text input into the scalar a clause compares against. */
97
138
  export declare function scalarFromText(option: FilterableFieldOption | undefined, raw: string): FilterScalar;
@@ -119,7 +160,12 @@ export declare function validateClauses(collection: CollectionDef, clauses: read
119
160
  * predicate; fixtures/tests that don't surface `$state` get the safe default.
120
161
  */
121
162
  export type IsPublished = (doc: CmsDocument) => boolean;
122
- /** Does a document satisfy one clause? Same semantics as the content accessors. */
163
+ /**
164
+ * Does a document satisfy one clause? Identical verdict to the content accessors'
165
+ * delivery/listing path: both reduce to `@elytracms/core/content`'s
166
+ * `matchesFieldValue` over the RAW field value (so `contains`/`empty`/multiple-
167
+ * relation membership all behave the same on the table as on the live site).
168
+ */
123
169
  export declare function clauseMatches(collection: CollectionDef, doc: CmsDocument, localeConfig: LocaleConfig, clause: FilterClause, isPublished?: IsPublished): boolean;
124
170
  /**
125
171
  * Sort documents by one column in the canonical deterministic order
@@ -157,7 +203,11 @@ export declare const configViewSchema: z.ZodObject<{
157
203
  filters: z.ZodOptional<z.ZodArray<z.ZodObject<{
158
204
  field: z.ZodString;
159
205
  op: z.ZodEnum<{
206
+ empty: "empty";
207
+ contains: "contains";
160
208
  in: "in";
209
+ endsWith: "endsWith";
210
+ startsWith: "startsWith";
161
211
  eq: "eq";
162
212
  gt: "gt";
163
213
  gte: "gte";