diff --git a/.agents/skills/shade-component-decision/SKILL.md b/.agents/skills/shade-component-decision/SKILL.md index 49cbeb2b172..fb5b70283d5 100644 --- a/.agents/skills/shade-component-decision/SKILL.md +++ b/.agents/skills/shade-component-decision/SKILL.md @@ -70,4 +70,5 @@ Each layer can use anything **below** it. The reverse is forbidden. ## Source of truth -Full rules: `apps/shade/AGENTS.md`. Human-facing: Storybook → Overview / Layers. +Storybook → Overview / Layers owns the layer model and promotion rules. +Overview / Contributing owns the implementation requirements. diff --git a/.agents/skills/shade-dropdown-surface-contract/SKILL.md b/.agents/skills/shade-dropdown-surface-contract/SKILL.md index d2008e1827d..5567d3fe628 100644 --- a/.agents/skills/shade-dropdown-surface-contract/SKILL.md +++ b/.agents/skills/shade-dropdown-surface-contract/SKILL.md @@ -72,4 +72,5 @@ Hover/active/selected state tokens (`--interactive-hover`, `--button-hover`, `-- ## Source of truth -`apps/shade/AGENTS.md` (Tokens & dark mode → Dropdown surface contract). Storybook → Tokens / Tokens Guide. +The `DropdownMenu`, `Select`, and `Popover` component files define the current +surface contract. Storybook → Tokens / Tokens Guide explains the token model. diff --git a/.agents/skills/shade-new-component/SKILL.md b/.agents/skills/shade-new-component/SKILL.md index 3f8d1be58bb..196e9ead511 100644 --- a/.agents/skills/shade-new-component/SKILL.md +++ b/.agents/skills/shade-new-component/SKILL.md @@ -114,4 +114,4 @@ See `shade-tokens-not-hex` and `shade-no-dark-variants`. ## Source of truth -`apps/shade/AGENTS.md`. Human docs: Storybook → Overview / Contributing. +Storybook → Overview / Contributing and the component's stories. diff --git a/.agents/skills/shade-page-templates/SKILL.md b/.agents/skills/shade-page-templates/SKILL.md index 1416a307d99..1a79a0b7559 100644 --- a/.agents/skills/shade-page-templates/SKILL.md +++ b/.agents/skills/shade-page-templates/SKILL.md @@ -94,4 +94,5 @@ If you're tempted to force a non-list shape into `ListPage`, stop and check whet ## Source of truth -`apps/shade/AGENTS.md`. Human docs: Storybook → Page Templates / Page Types. +Storybook → Page Templates / Page Types and the `ListPage` and `PageHeader` +stories. diff --git a/.agents/skills/shade-shadcn-install/SKILL.md b/.agents/skills/shade-shadcn-install/SKILL.md index 3fb440fcc48..9d5e91e8bc8 100644 --- a/.agents/skills/shade-shadcn-install/SKILL.md +++ b/.agents/skills/shade-shadcn-install/SKILL.md @@ -58,4 +58,5 @@ Raw ShadCN output is not Shade-quality yet. Do all of these: ## Source of truth -`apps/shade/AGENTS.md`, Storybook → Overview / Contributing. +Storybook → Overview / Contributing. This skill adds the agent-specific safety +steps for running the destructive ShadCN CLI. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba606ade48e..29141787ce3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,12 +155,15 @@ jobs: - 'scripts/**' docs: - '**/*.md' + - '**/*.mdx' - '.agents/**' - '.claude/**' - '.github/workflows/ci.yml' - 'package.json' - 'scripts/check-agent-skill-links.js' - 'scripts/test/check-agent-skill-links.test.js' + - 'scripts/check-agent-guidance.js' + - 'scripts/test/check-agent-guidance.test.js' package-standards: - 'packages/**' - 'package.json' @@ -191,8 +194,9 @@ jobs: - '!koenig/*/test/**' # Documentation does not affect Ghost runtime behaviour, even # when it lives inside a project root. Keep this after every - # positive pattern so micromatch cannot add Markdown files back. + # positive pattern so micromatch cannot add docs files back. - '!**/*.md' + - '!**/*.mdx' unit-test-globals: - 'vitest.config.mjs' core-unit-test-globals: @@ -200,6 +204,7 @@ jobs: - 'ghost/core/test/utils/vitest-*.ts' any-code: - '!**/*.md' + - '!**/*.mdx' - '!.devcontainer/**' - '!.vscode/**' - *renovate_only @@ -211,6 +216,7 @@ jobs: # added here as their conventions are confirmed. e2e: - '!**/*.md' + - '!**/*.mdx' - '!.devcontainer/**' - '!.vscode/**' - '!ghost/core/test/**' @@ -442,8 +448,11 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} - - name: Lint agent skills - run: node scripts/check-agent-skill-links.js + - name: Lint documentation guidance + run: | + node --test scripts/test/check-agent-guidance.test.js + node scripts/check-agent-skill-links.js + node scripts/check-agent-guidance.js job_lint_packages: name: Lint packages diff --git a/.lintstagedrc.cjs b/.lintstagedrc.cjs index 589d161526a..b0a1c3f0e17 100644 --- a/.lintstagedrc.cjs +++ b/.lintstagedrc.cjs @@ -91,6 +91,23 @@ function buildBoundaryCommand(files) { return `pnpm exec depcruise --config .dependency-cruiser.cjs -- ${shellQuote(relativeFiles)}`; } +function buildMarkdownCommands(files) { + const relativeFiles = files + .map(file => normalize(path.relative(ROOT, file))) + .filter(file => !file.startsWith('.changeset/')) + .filter(file => !file.split('/').some(part => part === 'fixture' || part === 'fixtures')); + + if (relativeFiles.length === 0) { + return []; + } + + const quotedFiles = shellQuote(relativeFiles); + return [ + `pnpm exec markdownlint-cli2 --config .markdownlint-cli2.jsonc ${quotedFiles}`, + `pnpm exec remark --use remark-validate-links --frail --quiet --no-stdout ${quotedFiles}` + ]; +} + module.exports = { '*.{js,ts,tsx,jsx,cjs}': (files) => { const groups = new Map(); @@ -109,5 +126,12 @@ module.exports = { 'ghost/core/core/{server,shared,frontend}/**/*.{js,ts}': (files) => buildBoundaryCommand(files), 'apps/{shade,admin-x-framework,activitypub,portal,comments-ui,signup-form,sodo-search,announcement-bar,admin-toolbar}/src/**/*.{js,ts,tsx,jsx}': (files) => - buildBoundaryCommand(files) + buildBoundaryCommand(files), + '**/*.md': buildMarkdownCommands, + '{**/AGENTS.md,scripts/check-agent-guidance.js}': () => + 'pnpm lint:agent-guidance', + '{.agents/skills/**,.claude/skills/**,scripts/check-agent-skill-links.js}': () => + 'pnpm lint:agent-skills', + '{package.json,pnpm-workspace.yaml,packages/**/package.json,packages/_template/**,scripts/check-internal-packages.js,scripts/create-package.js,scripts/lib/constants.js,scripts/lib/package-template.js}': () => + 'pnpm lint:packages' }; diff --git a/apps/admin/src/members/components/members-filters.tsx b/apps/admin/src/members/components/members-filters.tsx index 21cd0f60679..5138f7b20c2 100644 --- a/apps/admin/src/members/components/members-filters.tsx +++ b/apps/admin/src/members/components/members-filters.tsx @@ -13,6 +13,9 @@ import {getSettingValue, useBrowseSettings} from '@tryghost/admin-x-framework/ap import {getSiteTimezone} from '@tryghost/admin-x-framework/utils/get-site-timezone'; import {useBrowseNewsletters} from '@tryghost/admin-x-framework/api/newsletters'; import {useBrowseOffers} from '@tryghost/admin-x-framework/api/offers'; +import {useFeatureFlag} from '@tryghost/admin-x-framework/hooks'; +import {useBrowseMemberCustomFields, useBrowseMemberCustomFieldsIncludingArchived} from '@tryghost/admin-x-framework/api/member-custom-fields'; +import type {MemberCustomField} from '@tryghost/admin-x-framework/api/member-custom-fields'; import {useEmailPostValueSource, useLabelValueSource, usePostResourceValueSource, useTierValueSource} from '@/shared/filter-sources'; import type {MemberView} from '@/members/hooks/use-member-views'; @@ -27,6 +30,7 @@ interface MembersFiltersProps { } const EMPTY_OFFERS: typeof buildOfferOptions extends (offers: infer T) => unknown ? T : never = []; +const EMPTY_CUSTOM_FIELDS: MemberCustomField[] = []; function mapOfferRedemptionFilters( filters: Filter[], @@ -93,6 +97,26 @@ const MembersFilters: React.FC = ({ const emailValueSource = useEmailPostValueSource(); const labelValueSource = useLabelValueSource(); const {valueSource: tierValueSource, hasMultipleTiers} = useTierValueSource(); + const customFieldsEnabled = useFeatureFlag('membersCustomFields'); + // The picker lists active fields — the endpoint the members page has always used. + const {data: customFieldsData} = useBrowseMemberCustomFields({enabled: customFieldsEnabled}); + const customFields = customFieldsData?.members_custom_fields ?? EMPTY_CUSTOM_FIELDS; + const referencedCustomFieldKeys = useMemo(() => new Set( + filters + .map(filter => filter.field) + .filter(field => field.startsWith('custom_field.')) + .map(field => field.slice('custom_field.'.length)) + .filter(Boolean) + ), [filters]); + // Only when the current filter references a custom field do we also pull the archived + // ones, so a saved segment on a since-archived field still renders its read-only pill. + // Skipped otherwise, so the common members view makes no extra request. + const {data: archivedCustomFieldsData} = useBrowseMemberCustomFieldsIncludingArchived({ + enabled: customFieldsEnabled && referencedCustomFieldKeys.size > 0 + }); + const archivedCustomFields = useMemo(() => (archivedCustomFieldsData?.members_custom_fields ?? EMPTY_CUSTOM_FIELDS) + .filter(field => field.status === 'archived' && referencedCustomFieldKeys.has(field.key)) + .map(field => ({key: field.key, name: field.name})), [archivedCustomFieldsData, referencedCustomFieldKeys]); const filterFields = useMemberFilterFields({ newsletters, @@ -109,7 +133,10 @@ const MembersFilters: React.FC = ({ membersTrackSources, emailTrackOpens, emailTrackClicks, - siteTimezone + siteTimezone, + customFieldsEnabled, + customFields, + archivedCustomFields }); const hasFilters = filters.length > 0; diff --git a/apps/admin/src/members/custom-field-filter-renderer.tsx b/apps/admin/src/members/custom-field-filter-renderer.tsx new file mode 100644 index 00000000000..695e8d0a2be --- /dev/null +++ b/apps/admin/src/members/custom-field-filter-renderer.tsx @@ -0,0 +1,97 @@ +import React, {useEffect} from 'react'; +import {CUSTOM_FIELD_OPERATORS, CUSTOM_FIELD_SET_OPERATORS} from './member-fields'; +import {FilterSegmentInput, FilterSegmentSelect} from '@tryghost/shade/patterns'; +import {createOperatorOptions} from '@/shared/filters'; +import {memberCustomFieldParts, useBrowseMemberCustomFieldsIncludingArchived} from '@tryghost/admin-x-framework/api/member-custom-fields'; +import type {CustomRendererProps} from '@tryghost/shade/patterns'; + +// The dropdown entry has already chosen the field (its key is in `field.key` as +// `custom_field.`), so this renders only what's left in the pill: for a +// composite field a part selector (with "Any" for the whole field), then the +// operator, then the value. The predicate carries [subfield, value]; subfield is '' +// for a scalar field or the "Any" whole-field set/unset case. The operator lives here +// because its valid set depends on the part chosen here. + +const KEY_PREFIX = 'custom_field.'; + +const CustomFieldFilterRenderer: React.FC> = ({field, values, onChange, operator, onOperatorChange, readOnly}) => { + // Include-archived so an archived composite field's pill can still resolve its parts + // and show which one the saved segment filters on. + const {data} = useBrowseMemberCustomFieldsIncludingArchived(); + const definitions = data?.members_custom_fields ?? []; + + const fieldKey = (field.key ?? '').slice(KEY_PREFIX.length); + const definition = definitions.find(candidate => candidate.key === fieldKey); + // The shared catalog decides which parts a type has and what they are called; a scalar + // field has none. Its keys are the ones the predicate carries. + const parts = definition + ? (memberCustomFieldParts(definition.type) ?? []).map(({key, label}) => ({value: key, label})) + : []; + // Name the field in each segment's aria-label so two custom-field pills on one row + // are distinguishable to a screen reader rather than all reading "Operator"/"Value". + const fieldLabel = field.label ?? definition?.name ?? 'Custom field'; + const isComposite = parts.length > 0; + + const [subfield = '', value = ''] = values; + const isWholeField = subfield === ''; + + // A composite's "Any" (whole field) only supports set / not-set — "Any contains X" + // is meaningless. A specific part, and a scalar field, support the value operators + // and set / not-set. Only "Any" restricts the set, so only it needs the operator + // coerced when the part selection changes — done in an effect rather than the change + // handler, because the framework's filter update reads a stale list within a tick, so + // a value change and an operator change can't both land in the same one. + const operators = isComposite && isWholeField + ? CUSTOM_FIELD_SET_OPERATORS + : CUSTOM_FIELD_OPERATORS; + + useEffect(() => { + // A read-only pill never rewrites its own operator; it just displays what's set. + if (readOnly || !onOperatorChange || operators.includes(operator)) { + return; + } + onOperatorChange('is-set'); + }, [readOnly, operator, operators, onOperatorChange]); + + const needsValue = !CUSTOM_FIELD_SET_OPERATORS.includes(operator); + const partOptions = [{value: '', label: 'Any'}, ...parts]; + + return ( + <> + {isComposite && ( + onChange([nextSubfield, value])} + /> + )} + + {onOperatorChange && ( + + )} + + {needsValue && ( + onChange([subfield, nextValue])} + /> + )} + + ); +}; + +export default CustomFieldFilterRenderer; diff --git a/apps/admin/src/members/member-fields.test.ts b/apps/admin/src/members/member-fields.test.ts index 9e880b9ad68..c49858123c9 100644 --- a/apps/admin/src/members/member-fields.test.ts +++ b/apps/admin/src/members/member-fields.test.ts @@ -42,7 +42,8 @@ describe('memberFields', () => { 'clicked_links.post_id', 'newsletter_feedback', 'offer_redemptions', - 'count.active_stripe_customers' + 'count.active_stripe_customers', + 'custom_field.:key' ]); }); diff --git a/apps/admin/src/members/member-fields.ts b/apps/admin/src/members/member-fields.ts index 29164ead7e5..eb78c7559f4 100644 --- a/apps/admin/src/members/member-fields.ts +++ b/apps/admin/src/members/member-fields.ts @@ -133,6 +133,80 @@ const multipleActiveSubscriptionsCodec: FilterCodec = { } }; +// Presence operators: the extra an optional, per-member field has that a table column does +// not — a column is always set, so no built-in field offers these. +export const CUSTOM_FIELD_SET_OPERATORS: readonly string[] = ['is-set', 'is-not-set']; + +// A custom text field's operators, composed from the shared groups so the members filter +// keeps one vocabulary: the equality pair (is / is-not) the scalar fields use, then the +// text matching operators (contains, starts-with, …) with their duplicate `is` dropped, +// then presence. Labels come from the shared createOperatorOptions default (dash to space), +// which reads every one of these correctly, so no label map is needed. +export const CUSTOM_FIELD_OPERATORS: readonly string[] = [ + ...SCALAR_OPERATORS, + ...TEXT_OPERATORS.filter(op => !(SCALAR_OPERATORS as readonly string[]).includes(op)), + ...CUSTOM_FIELD_SET_OPERATORS +]; + +// NQL operator symbol for each value operator. The field is named in the value +// position (`custom_fields.key:'…'`) so its key can carry hyphens; the value is +// matched on `custom_fields.value` (scalar) or `custom_fields.value.` +// (address), which the members filter relation maps onto the real columns. +const CUSTOM_FIELD_VALUE_SYMBOLS: Record = { + is: '', + 'is-not': '-', + contains: '~', + 'does-not-contain': '-~', + 'starts-with': '~^', + 'ends-with': '~$' +}; + +const customFieldsCodec: FilterCodec = { + // Parsing a grouped custom-field expression back to a predicate is bespoke — + // its field and part are spread across a `(key + value)` pair — so it's handled + // by a compound matcher in member-filter-query.ts, not here. + parse() { + return null; + }, + // The field's stable key comes from the dropdown entry (`custom_field.`, + // resolved into `ctx.params.key`); the predicate carries only [subfield, value], + // with subfield '' for a scalar field or the "Any" (whole-field set/unset) case. + serialize(predicate, ctx) { + const fieldKey = ctx.params.key; + const [subfield, value] = predicate.values as [string, string]; + + if (!fieldKey) { + return null; + } + + const keyClause = `custom_fields.key:${escapeNqlString(fieldKey)}`; + + // set / not-set target a part's presence when a part is chosen (`path`), or the + // whole field otherwise (the bare key / its negation). + if (predicate.operator === 'is-set') { + return subfield + ? [`(${keyClause}+custom_fields.path:${escapeNqlString(subfield)})`] + : [keyClause]; + } + + if (predicate.operator === 'is-not-set') { + return subfield + ? [`(${keyClause}+custom_fields.path:-${escapeNqlString(subfield)})`] + : [`custom_fields.key:-${escapeNqlString(fieldKey)}`]; + } + + const symbol = CUSTOM_FIELD_VALUE_SYMBOLS[predicate.operator]; + + if (symbol === undefined || value === undefined || value === null || value === '') { + return null; + } + + const valueKey = subfield ? `custom_fields.value.${subfield}` : 'custom_fields.value'; + + return [`(${keyClause}+${valueKey}:${symbol}${escapeNqlString(String(value))})`]; + } +}; + const baseMemberFields = defineFields({ name: { operators: TEXT_OPERATORS, @@ -453,6 +527,18 @@ const baseMemberFields = defineFields({ {value: 'false', label: 'No'} ], codec: multipleActiveSubscriptionsCodec + }, + // Each defined custom field is its own filter, named directly in the dropdown + // (`custom_field.`), so this template supplies the shared operators and codec; + // use-member-filter-fields builds one entry per field from the definitions. + 'custom_field.:key': { + operators: CUSTOM_FIELD_OPERATORS, + ui: { + label: 'Custom field', + type: 'custom', + component: 'custom-field' + }, + codec: customFieldsCodec } }); diff --git a/apps/admin/src/members/member-filter-query.test.ts b/apps/admin/src/members/member-filter-query.test.ts index c9af7f8b0a2..bbbb058fdef 100644 --- a/apps/admin/src/members/member-filter-query.test.ts +++ b/apps/admin/src/members/member-filter-query.test.ts @@ -311,3 +311,53 @@ describe('isPredicateEnabled', () => { )).toBe(false); }); }); + +describe('member-filter-query - custom fields', () => { + // Serialize each operator to NQL, then parse it back, and confirm the predicate + // survives the round trip a saved segment relies on. Each field is its own + // predicate keyed `custom_field.`; `values` is [subfield, value]. + const cases: Array<{field: string; operator: string; values: [string, string]; nql: string}> = [ + {field: 'custom_field.company', operator: 'is', values: ['', 'Ghost'], nql: "(custom_fields.key:'company'+custom_fields.value:'Ghost')"}, + {field: 'custom_field.company', operator: 'is-not', values: ['', 'Ghost'], nql: "(custom_fields.key:'company'+custom_fields.value:-'Ghost')"}, + {field: 'custom_field.company', operator: 'contains', values: ['', 'host'], nql: "(custom_fields.key:'company'+custom_fields.value:~'host')"}, + {field: 'custom_field.company', operator: 'does-not-contain', values: ['', 'host'], nql: "(custom_fields.key:'company'+custom_fields.value:-~'host')"}, + {field: 'custom_field.company', operator: 'starts-with', values: ['', 'Gh'], nql: "(custom_fields.key:'company'+custom_fields.value:~^'Gh')"}, + {field: 'custom_field.company', operator: 'ends-with', values: ['', 'st'], nql: "(custom_fields.key:'company'+custom_fields.value:~$'st')"}, + // A value that ends with a literal `$` must survive as contains, not be + // misread as ends-with when the regex source (`5\$`) is parsed back. + {field: 'custom_field.company', operator: 'contains', values: ['', '5$'], nql: "(custom_fields.key:'company'+custom_fields.value:~'5$')"}, + {field: 'custom_field.shipping-address', operator: 'is', values: ['country', 'GB'], nql: "(custom_fields.key:'shipping-address'+custom_fields.value.country:'GB')"}, + {field: 'custom_field.shipping-address', operator: 'is-not', values: ['country', 'GB'], nql: "(custom_fields.key:'shipping-address'+custom_fields.value.country:-'GB')"}, + {field: 'custom_field.phone', operator: 'is-set', values: ['', ''], nql: "custom_fields.key:'phone'"}, + {field: 'custom_field.phone', operator: 'is-not-set', values: ['', ''], nql: "custom_fields.key:-'phone'"}, + // A part's set / not-set targets its presence via `path`, not the whole field. + {field: 'custom_field.shipping-address', operator: 'is-set', values: ['country', ''], nql: "(custom_fields.key:'shipping-address'+custom_fields.path:'country')"}, + {field: 'custom_field.shipping-address', operator: 'is-not-set', values: ['country', ''], nql: "(custom_fields.key:'shipping-address'+custom_fields.path:-'country')"} + ]; + + it.each(cases)('serializes $field $operator to the expected NQL', ({field, operator, values, nql}) => { + const serialized = serializeMemberFilters([{id: 'x', field, operator, values}], 'UTC'); + expect(serialized).toBe(nql); + }); + + it.each(cases)('parses $field $operator back into the same predicate', ({field, operator, values, nql}) => { + expect(stripIds(parseMemberFilter(nql, 'UTC'))).toEqual([ + {field, operator, values} + ]); + }); + + it.each(cases)('round-trips $field $operator (predicate -> nql -> predicate)', ({field, operator, values}) => { + const predicate: FilterPredicate = {id: 'x', field, operator, values}; + const nql = serializeMemberFilters([predicate], 'UTC'); + expect(stripIds(parseMemberFilter(nql, 'UTC'))).toEqual([ + {field, operator, values} + ]); + }); + + it('is enabled for every operator the custom field advertises', () => { + const fields = getMemberFields(); + for (const {field, operator, values} of cases) { + expect(isPredicateEnabled({field, operator, values}, fields)).toBe(true); + } + }); +}); diff --git a/apps/admin/src/members/member-filter-query.ts b/apps/admin/src/members/member-filter-query.ts index db9c2d82515..6b1917d004a 100644 --- a/apps/admin/src/members/member-filter-query.ts +++ b/apps/admin/src/members/member-filter-query.ts @@ -180,10 +180,164 @@ function matchFeedbackGroupedNode(node: AstNode): ParsedPredicate | null { }; } +// A trailing `$` is an end anchor only when it isn't escaped: a value containing +// a literal `$` (contains `5$`) reaches here as the source `5\$`, which must not +// be read as ends-with. An odd run of backslashes before the `$` escapes it. +function endsWithAnchor(source: string): boolean { + if (!source.endsWith('$')) { + return false; + } + let backslashes = 0; + for (let i = source.length - 2; i >= 0 && source[i] === '\\'; i -= 1) { + backslashes += 1; + } + return backslashes % 2 === 0; +} + +// A regex value read back into a text operator by its anchors, mirroring the +// serialize symbols in member-fields.ts (`~` contains, `~^` starts, `~$` ends). +// A literal `^` in a value is escaped to `\^` so it never leads, but a literal +// `$` escapes to `\$` and still ends the source, hence the anchor check above. +// `$not` is only ever emitted by `does-not-contain` (an unanchored regex), so a +// negated pattern always maps back to that operator. +function regexToOperator(pattern: RegExp, negated: boolean): {operator: string; value: string} { + const source = pattern.source; + const startsWith = source.startsWith('^'); + const endsWith = endsWithAnchor(source); + + let base: string; + let body: string; + if (startsWith && !endsWith) { + base = 'starts-with'; + body = source.slice(1); + } else if (endsWith && !startsWith) { + base = 'ends-with'; + body = source.slice(0, -1); + } else { + base = 'contains'; + body = source; + } + + const value = body.replace(/\\([\\.^$|?*+()[\]{}/-])/g, '$1'); + return {operator: negated ? 'does-not-contain' : base, value}; +} + +// The value NQL a custom-field predicate carries, read back into a (operator, +// value) pair. nql represents `:~x` as {$regex: /x/} and its negation as +// {$not: /x/}; a bare string is `is` and {$ne} is `is-not`. Returns null for a +// shape we don't emit. +function interpretCustomFieldValue(raw: unknown): {operator: string; value: string} | null { + if (typeof raw === 'string') { + return {operator: 'is', value: raw}; + } + + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + const object = raw as Record; + + if (typeof object.$ne === 'string') { + return {operator: 'is-not', value: object.$ne}; + } + + if (object.$regex instanceof RegExp) { + return regexToOperator(object.$regex, false); + } + + if (object.$not instanceof RegExp) { + return regexToOperator(object.$not, true); + } + } + + return null; +} + +// A custom-field filter is `(custom_fields.key:''+custom_fields.value[.sub]:)`, +// or the flat `custom_fields.key:''` / `:-''` for set / not set. Each field +// is its own predicate keyed `custom_field.`, so the field's stable key becomes +// part of the predicate field and the remaining `values` are [subfield, value] +// (subfield '' for a scalar field or the whole-field set/unset case). This can't ride +// the generic parser because the key lives in the value of the key clause, not the key. +function matchCustomFieldNode(node: AstNode): ParsedPredicate | null { + const compound = getCompoundChildren(node); + + if (!compound) { + const keyValue = node['custom_fields.key']; + + if (typeof keyValue === 'string') { + return {field: `custom_field.${keyValue}`, operator: 'is-set', values: ['', '']}; + } + + if (keyValue && typeof keyValue === 'object' && !Array.isArray(keyValue) && typeof (keyValue as Record).$ne === 'string') { + return {field: `custom_field.${(keyValue as Record).$ne}`, operator: 'is-not-set', values: ['', '']}; + } + + return null; + } + + if (compound.operator !== '$and' || compound.children.length !== 2) { + return null; + } + + let fieldKey: string | undefined; + let valueEntry: {subfield: string; raw: unknown} | undefined; + let pathEntry: {subfield: string; negated: boolean} | undefined; + + for (const child of compound.children) { + if (typeof child['custom_fields.key'] === 'string') { + fieldKey = child['custom_fields.key']; + } + + for (const childKey of Object.keys(child)) { + if (childKey === 'custom_fields.value') { + valueEntry = {subfield: '', raw: child[childKey]}; + } else if (childKey.startsWith('custom_fields.value.')) { + valueEntry = {subfield: childKey.slice('custom_fields.value.'.length), raw: child[childKey]}; + } else if (childKey === 'custom_fields.path') { + const raw = child[childKey]; + + if (typeof raw === 'string') { + pathEntry = {subfield: raw, negated: false}; + } else if (raw && typeof raw === 'object' && !Array.isArray(raw) && typeof (raw as Record).$ne === 'string') { + pathEntry = {subfield: (raw as Record).$ne, negated: true}; + } + } + } + } + + if (!fieldKey) { + return null; + } + + // A `path` clause is a part's set / not-set: its presence, carrying no value. + if (pathEntry) { + return { + field: `custom_field.${fieldKey}`, + operator: pathEntry.negated ? 'is-not-set' : 'is-set', + values: [pathEntry.subfield, ''] + }; + } + + if (!valueEntry) { + return null; + } + + const interpreted = interpretCustomFieldValue(valueEntry.raw); + + if (!interpreted) { + return null; + } + + return { + field: `custom_field.${fieldKey}`, + operator: interpreted.operator, + values: [valueEntry.subfield, interpreted.value] + }; +} + const MEMBER_COMPOUND_MATCHERS: CompoundMatcher[] = [ matchSubscribedNode, matchNewsletterGroupedNode, - matchFeedbackGroupedNode + matchFeedbackGroupedNode, + matchCustomFieldNode ]; function parseMemberNode(node: AstNode, timezone: string): ParsedPredicate[] { diff --git a/apps/admin/src/members/use-member-filter-fields.test.ts b/apps/admin/src/members/use-member-filter-fields.test.ts index bb3a3ebeca4..fbaf7ca9b21 100644 --- a/apps/admin/src/members/use-member-filter-fields.test.ts +++ b/apps/admin/src/members/use-member-filter-fields.test.ts @@ -225,6 +225,44 @@ describe('useMemberFilterFields', () => { expect(subscriptionFields.map(field => field.key)).not.toContain('count.active_stripe_customers'); }); + it('gives each defined custom field its own named entry', () => { + const {result} = renderHook(() => useMemberFilterFields({ + customFieldsEnabled: true, + customFields: [ + {key: 'shipping_address', name: 'Shipping address', type: 'address'}, + {key: 'job_title', name: 'Job title', type: 'short_text'} + ], + siteTimezone: 'UTC' + })); + + const customFields = result.current.find(group => group.group === 'Custom fields')?.fields ?? []; + + expect(customFields.map(field => ({key: field.key, label: field.label}))).toEqual([ + {key: 'custom_field.shipping_address', label: 'Shipping address'}, + {key: 'custom_field.job_title', label: 'Job title'} + ]); + }); + + it('omits the custom fields group when no fields are defined', () => { + const {result} = renderHook(() => useMemberFilterFields({ + customFieldsEnabled: true, + customFields: [], + siteTimezone: 'UTC' + })); + + expect(result.current.map(group => group.group)).not.toContain('Custom fields'); + }); + + it('omits the custom fields group when the flag is off', () => { + const {result} = renderHook(() => useMemberFilterFields({ + customFieldsEnabled: false, + customFields: [{key: 'job_title', name: 'Job title', type: 'short_text'}], + siteTimezone: 'UTC' + })); + + expect(result.current.map(group => group.group)).not.toContain('Custom fields'); + }); + it('hydrates grouped retention offers on the offer field', () => { const {result} = renderHook(() => useMemberFilterFields({ paidMembersEnabled: true, diff --git a/apps/admin/src/members/use-member-filter-fields.ts b/apps/admin/src/members/use-member-filter-fields.ts index 55c78492d7e..2a4b7f0ca80 100644 --- a/apps/admin/src/members/use-member-filter-fields.ts +++ b/apps/admin/src/members/use-member-filter-fields.ts @@ -1,10 +1,13 @@ import React, {useMemo} from 'react'; import {DATE_OPERATOR_LABELS, RELATIVE_DATE_OPERATOR_LABELS, createOperatorOptions, createRelativeDateRenderer, fieldHasRelativeOperator, getTodayInTimezone} from '@/shared/filters'; import {type FilterFieldConfig, type FilterFieldGroup, type FilterOption, type ValueSource} from '@tryghost/shade/patterns'; +import CustomFieldFilterRenderer from './custom-field-filter-renderer'; +import CustomFieldIcon from '@/shared/member-custom-fields/custom-field-icon'; import {LabelFilterRenderer} from '@/members/label-picker'; import {LucideIcon} from '@tryghost/shade/utils'; import {MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FIELD} from './multiple-active-subscriptions'; import {getMemberFields} from './member-fields'; +import type {MemberCustomField} from '@tryghost/admin-x-framework/api/member-custom-fields'; import type {Offer} from '@tryghost/admin-x-framework/api/offers'; interface UseMemberFilterFieldsOptions { @@ -22,12 +25,22 @@ interface UseMemberFilterFieldsOptions { membersTrackSources?: boolean; emailTrackOpens?: boolean; emailTrackClicks?: boolean; + customFieldsEnabled?: boolean; + customFields?: Array<{key: string; name: string; type: MemberCustomField['type']}>; + // Archived fields still referenced by the current filter. Rendered as disabled, + // removable-only pills so a saved segment stays visible and undoable even though + // the field is no longer offered in the picker. + archivedCustomFields?: Array<{key: string; name: string}>; siteTimezone?: string; } type OfferOption = FilterOption; type SearchableFieldOverrides = Pick; +// How many custom fields the picker shows before "Show more" — the same preview +// size the settings list uses. The rest stay searchable and resolvable. +const CUSTOM_FIELDS_PREVIEW_LIMIT = 5; + const MEMBER_OPERATOR_LABELS: Record = { 'is-any': 'is any of', 'is-not-any': 'is none of', @@ -255,6 +268,9 @@ export function useMemberFilterFields({ membersTrackSources = false, emailTrackOpens = false, emailTrackClicks = false, + customFieldsEnabled = false, + customFields = [], + archivedCustomFields = [], siteTimezone = 'UTC' }: UseMemberFilterFieldsOptions): FilterFieldGroup[] { return useMemo(() => { @@ -266,9 +282,14 @@ export function useMemberFilterFields({ overrides: Partial = {}, operatorLabels: Record = MEMBER_OPERATOR_LABELS ): FilterFieldConfig { - const field = key.startsWith('newsletters.') - ? fields['newsletters.:slug'] - : fields[key as MemberFieldKey]; + let field; + if (key.startsWith('newsletters.')) { + field = fields['newsletters.:slug']; + } else if (key.startsWith('custom_field.')) { + field = fields['custom_field.:key']; + } else { + field = fields[key as MemberFieldKey]; + } return { key, @@ -345,6 +366,49 @@ export function useMemberFilterFields({ groups.push({group: 'Basic', fields: basicFields}); + // Each defined custom field is its own named entry, so a publisher can search + // for "Shipping address" directly rather than reaching it through a generic + // "Custom field" door. A simple field filters on its value; a composite field's + // renderer opens its parts (plus "Any") in the pill. + if (customFieldsEnabled) { + const customFieldFields = customFields.map(field => createFieldConfig(`custom_field.${field.key}`, { + label: field.name, + // The dropdown entry and the added filter show the field type's own icon + // rather than a generic custom-field mark. + icon: React.createElement(CustomFieldIcon, {type: field.type, className: 'size-4'}), + // Text fields default to "contains" to match native Name/Email; a + // composite defaults to whole-field "is set" (the renderer coerces it). + defaultOperator: 'contains', + // The field's type decides its parts and operators, so the operator + // control lives in the renderer, after any part is chosen. + renderOperatorInValue: true, + customRenderer: props => React.createElement(CustomFieldFilterRenderer, props as React.ComponentProps) + })); + + // An archived field the current filter still references: a disabled, + // removable-only pill with an archive icon. Its key is already in the filter, + // so the picker's own de-dup keeps it out of the add-list — it only ever + // renders as an existing pill. + const archivedFieldFields = archivedCustomFields.map(field => createFieldConfig(`custom_field.${field.key}`, { + label: field.name, + icon: React.createElement(LucideIcon.Archive, {className: 'size-4'}), + // Read-only: the operator and value stay visible so the segment reads + // clearly, but the field is gone from the picker, so the pill can only + // be removed, never re-edited. + readOnly: true, + renderOperatorInValue: true, + customRenderer: props => React.createElement(CustomFieldFilterRenderer, props as React.ComponentProps) + })); + + const allCustomFieldFields = [...customFieldFields, ...archivedFieldFields]; + + // Nothing defined yet means nothing to filter on, so the group stays out of + // the picker entirely rather than showing a section that can't be used. + if (allCustomFieldFields.length > 0) { + groups.push({group: 'Custom fields', fields: allCustomFieldFields, previewLimit: CUSTOM_FIELDS_PREVIEW_LIMIT}); + } + } + if (activeNewsletters.length > 1) { const newsletterFields: FilterFieldConfig[] = [ createFieldConfig('subscribed', { @@ -452,6 +516,9 @@ export function useMemberFilterFields({ }, [ emailFiltersEnabled, emailValueSource, + customFieldsEnabled, + customFields, + archivedCustomFields, emailTrackClicks, emailTrackOpens, hasMultipleTiers, diff --git a/apps/admin/src/shared/filters/filter-codec-roundtrip.test.ts b/apps/admin/src/shared/filters/filter-codec-roundtrip.test.ts new file mode 100644 index 00000000000..c7e454dbc9b --- /dev/null +++ b/apps/admin/src/shared/filters/filter-codec-roundtrip.test.ts @@ -0,0 +1,148 @@ +import nql from '@tryghost/nql-lang'; +import {dateCodec, numberCodec, scalarCodec, setCodec, textCodec} from './filter-codecs'; +import {describe, expect, it} from 'vitest'; +import type {CodecContext, FilterCodec, FilterPredicate} from './filter-types'; + +// What a saved segment actually relies on: a predicate the publisher built in the UI +// is serialized to NQL, stored, and read back the next time the page loads. Every +// codec must survive that trip for every operator it advertises, whatever the value +// holds — the per-codec tests above assert one direction at a time against hand-written +// NQL, which is how the anchor readers in this engine and in member-filter-query.ts +// drifted apart without a test noticing. + +function context(key: string, timezone = 'UTC'): CodecContext { + return {key, pattern: key, params: {}, timezone}; +} + +function roundTrip(codec: FilterCodec, predicate: Omit, ctx: CodecContext) { + const clauses = codec.serialize({id: 'x', ...predicate}, ctx); + + if (!clauses) { + throw new Error(`serialize returned null for ${predicate.operator}`); + } + + const node = nql.parse(clauses.join('+'), {preserveRelativeDates: true}); + + return codec.parse(node, ctx); +} + +// Values chosen for what they do to the regex the text codec builds: `$` and `^` are +// the anchors the parse side reads operators from, so a value containing one is the +// case where escaping and anchoring have to be told apart. +const TEXT_VALUES = [ + 'Ghost', + 'two words', + '5$', + '$5', + '^caret', + 'a.b', + "it's", + 'back\\slash', + '-leading-hyphen', + 'trailing$' +]; + +const TEXT_OPERATORS = [ + 'is', + 'contains', + 'does-not-contain', + 'starts-with', + 'does-not-start-with', + 'ends-with', + 'does-not-end-with' +]; + +describe('codec round trips', () => { + describe('textCodec', () => { + const ctx = context('email'); + const codec = textCodec(); + + for (const operator of TEXT_OPERATORS) { + it.each(TEXT_VALUES)(`round-trips ${operator} %j`, (value) => { + expect(roundTrip(codec, {field: 'email', operator, values: [value]}, ctx)).toEqual({ + field: 'email', + operator, + values: [value] + }); + }); + } + }); + + describe('scalarCodec', () => { + const ctx = context('status'); + const codec = scalarCodec(); + + for (const operator of ['is', 'is-not']) { + it.each(['paid', 'two words', '-leading', "it's", 'a.b'])(`round-trips ${operator} %j`, (value) => { + expect(roundTrip(codec, {field: 'status', operator, values: [value]}, ctx)).toEqual({ + field: 'status', + operator, + values: [value] + }); + }); + } + }); + + describe('setCodec', () => { + const ctx = context('label'); + const codec = setCodec(); + + for (const operator of ['is-any', 'is-not-any']) { + it.each([ + [['vip']], + [['vip', 'founder']], + [['two words', 'a.b']] + ])(`round-trips ${operator} %j`, (values) => { + const parsed = roundTrip(codec, {field: 'label', operator, values}, ctx); + + expect(parsed?.operator).toBe(operator); + expect(parsed?.values).toEqual([...values].sort((left, right) => left.localeCompare(right))); + }); + } + }); + + describe('numberCodec', () => { + const ctx = context('email_count'); + const codec = numberCodec(); + + for (const operator of ['is', 'is-greater', 'is-or-greater', 'is-less', 'is-or-less']) { + it.each([0, 1, 42])(`round-trips ${operator} %j`, (value) => { + expect(roundTrip(codec, {field: 'email_count', operator, values: [value]}, ctx)).toEqual({ + field: 'email_count', + operator, + values: [value] + }); + }); + } + }); + + describe('dateCodec', () => { + const codec = dateCodec(); + + for (const timezone of ['UTC', 'Europe/Berlin', 'America/Los_Angeles']) { + for (const operator of ['is-less', 'is-or-less', 'is-greater', 'is-or-greater']) { + it(`round-trips ${operator} in ${timezone}`, () => { + const ctx = context('created_at', timezone); + + expect(roundTrip(codec, {field: 'created_at', operator, values: ['2026-08-11']}, ctx)).toEqual({ + field: 'created_at', + operator, + values: ['2026-08-11'] + }); + }); + } + + for (const operator of ['in-the-last', 'in-the-next']) { + it(`round-trips ${operator} in ${timezone}`, () => { + const ctx = context('created_at', timezone); + + expect(roundTrip(codec, {field: 'created_at', operator, values: [30]}, ctx)).toEqual({ + field: 'created_at', + operator, + values: [30] + }); + }); + } + } + }); +}); diff --git a/apps/admin/src/shared/filters/filter-codecs.ts b/apps/admin/src/shared/filters/filter-codecs.ts index 6cd4900bbcd..60f8498e82b 100644 --- a/apps/admin/src/shared/filters/filter-codecs.ts +++ b/apps/admin/src/shared/filters/filter-codecs.ts @@ -83,38 +83,53 @@ function serializeScalarValue(value: unknown, config?: CodecConfig): string { return String(value); } -function extractRegexOperator(pattern: RegExp, negated = false): string { - const source = pattern.source; - const startsWith = source.startsWith('^'); - const endsWith = source.endsWith('$'); - - if (startsWith && endsWith) { - return negated ? 'does-not-contain' : 'contains'; +// A trailing `$` anchors the regex only when it isn't itself escaped: a value holding a +// literal `$` (contains `5$`) reaches here as the source `5\$`, which still ends in `$`. +// An odd run of backslashes before it means it is escaped, so it is part of the value. +// A literal `^` is always escaped to `\^`, so a leading `^` needs no such check. +function hasEndAnchor(source: string): boolean { + if (!source.endsWith('$')) { + return false; } - if (startsWith) { - return negated ? 'does-not-start-with' : 'starts-with'; - } + let backslashes = 0; - if (endsWith) { - return negated ? 'does-not-end-with' : 'ends-with'; + for (let index = source.length - 2; index >= 0 && source[index] === '\\'; index -= 1) { + backslashes += 1; } - return negated ? 'does-not-contain' : 'contains'; + return backslashes % 2 === 0; } -function normalizeRegexValue(pattern: RegExp): string { - let source = pattern.source; +// Which anchors a regex carries, and the value left once they are removed. Read together +// rather than one at a time: the operator and the value are two answers to the same +// question, and deciding the anchors twice is how a value could keep a `$` the operator +// had already consumed. +function decomposeRegex(pattern: RegExp): {anchorStart: boolean; anchorEnd: boolean; value: string} { + const source = pattern.source; + const anchorStart = source.startsWith('^'); + const anchorEnd = hasEndAnchor(source); + const body = source.slice(anchorStart ? 1 : 0, anchorEnd ? -1 : undefined); - if (source.startsWith('^')) { - source = source.slice(1); + return { + anchorStart, + anchorEnd, + value: body.replace(/\\([\\.^$|?*+()[\]{}/-])/g, '$1') + }; +} + +// Anchors read back into the operator that would have produced them. Both anchors is not +// an operator this codec emits, so it falls back to the unanchored reading. +function anchorsToOperator(anchorStart: boolean, anchorEnd: boolean, negated: boolean): string { + if (anchorStart && !anchorEnd) { + return negated ? 'does-not-start-with' : 'starts-with'; } - if (source.endsWith('$')) { - source = source.slice(0, -1); + if (anchorEnd && !anchorStart) { + return negated ? 'does-not-end-with' : 'ends-with'; } - return source.replace(/\\([\\.^$|?*+()[\]{}/-])/g, '$1'); + return negated ? 'does-not-contain' : 'contains'; } export function scalarCodec(config?: CodecConfig): FilterCodec { @@ -179,18 +194,22 @@ export function textCodec(config?: CodecConfig): FilterCodec { } if (comparator.operator === '$regex' && comparator.value instanceof RegExp) { + const {anchorStart, anchorEnd, value} = decomposeRegex(comparator.value); + return { field: ctx.key, - operator: extractRegexOperator(comparator.value), - values: [normalizeRegexValue(comparator.value)] + operator: anchorsToOperator(anchorStart, anchorEnd, false), + values: [value] }; } if (comparator.operator === '$not' && comparator.value instanceof RegExp) { + const {anchorStart, anchorEnd, value} = decomposeRegex(comparator.value); + return { field: ctx.key, - operator: extractRegexOperator(comparator.value, true), - values: [normalizeRegexValue(comparator.value)] + operator: anchorsToOperator(anchorStart, anchorEnd, true), + values: [value] }; } diff --git a/apps/admin/src/tags/detail/tag-code-injection-accordion.tsx b/apps/admin/src/tags/detail/tag-code-injection-accordion.tsx new file mode 100644 index 00000000000..d6875dd521f --- /dev/null +++ b/apps/admin/src/tags/detail/tag-code-injection-accordion.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import {Accordion, AccordionContent, AccordionItem, AccordionTrigger, Card, CodeEditor} from '@tryghost/shade/components'; +import {Stack} from '@tryghost/shade/primitives'; + +interface TagCodeInjectionAccordionProps { + disabled?: boolean; + headerValue: string; + footerValue: string; + onHeaderChange: (value: string) => void; + onFooterChange: (value: string) => void; +} + +const htmlExtensions = [() => import('@codemirror/lang-html').then(module => module.html())]; + +const TagCodeInjectionAccordion: React.FC = ({disabled, headerValue, footerValue, onHeaderChange, onFooterChange}) => { + const hasCodeInjection = Boolean(headerValue.trim() || footerValue.trim()); + + return ( + + + + + + Code injection + Add styles/scripts to the header and footer. + + + + + Tag header {'{{ghost_head}}'}} + value={headerValue} + onChange={onHeaderChange} + /> + Tag footer {'{{ghost_foot}}'}} + value={footerValue} + onChange={onFooterChange} + /> + + + + + + ); +}; + +export default TagCodeInjectionAccordion; diff --git a/apps/admin/src/tags/detail/tag-color-field.tsx b/apps/admin/src/tags/detail/tag-color-field.tsx index 975ce45be42..2885e5ceb1a 100644 --- a/apps/admin/src/tags/detail/tag-color-field.tsx +++ b/apps/admin/src/tags/detail/tag-color-field.tsx @@ -1,5 +1,7 @@ import React from 'react'; -import {InputGroup, InputGroupAddon, InputGroupInput, InputGroupText, Label} from '@tryghost/shade/components'; +import {InputGroup, InputGroupAddon, InputGroupInput, InputGroupText, Label, Popover, PopoverContent, PopoverTrigger} from '@tryghost/shade/components'; +import {ColorPicker, ColorPickerTrigger} from '@tryghost/shade/patterns'; +import {Stack} from '@tryghost/shade/primitives'; interface TagColorFieldProps { value: string; @@ -13,7 +15,7 @@ const HEX_COLOR_REGEX = /#[0-9A-Fa-f]{6}$/; /** * The tag accent colour control, arranged like Ember's `.input-color`: one - * bordered control with the swatch (the native colour-picker trigger) on the + * bordered control with the colour-picker trigger on the * left, a static `#` prefix, and the hex text input. Ports `tag-form.js` * `updateAccentColor` — immediate normalization keeps the form draft in sync * before keyboard saves, with the same error copy for a malformed hex value. @@ -21,6 +23,7 @@ const HEX_COLOR_REGEX = /#[0-9A-Fa-f]{6}$/; const TagColorField: React.FC = ({value, disabled, errorId, onChange, onError}) => { const [text, setText] = React.useState(value.replace(/^#/, '')); const lastValueRef = React.useRef(value); + const allowPickerChanges = React.useRef(false); // Adopt external changes (initial load, a background refetch) without // clobbering in-progress typing on unrelated re-renders. @@ -60,50 +63,58 @@ const TagColorField: React.FC = ({value, disabled, errorId, }; return ( -
- - - -
e.stopPropagation()} - > - { - setText(e.target.value.replace(/^#/, '')); - applyColor(e.target.value); - }} - /> -
- # -
- applyColor(e.target.value)} - onChange={(e) => { - setText(e.target.value); - applyColor(e.target.value); - }} - /> -
-
+ allowPickerChanges.current = false}> + + + + + + + + # + + applyColor(e.target.value)} + onChange={(e) => { + setText(e.target.value); + applyColor(e.target.value); + }} + /> + + + +
allowPickerChanges.current = true} + onKeyDownCapture={() => allowPickerChanges.current = true} + onPointerDownCapture={() => allowPickerChanges.current = true} + > + { + if (allowPickerChanges.current) { + setText(color.replace(/^#/, '')); + applyColor(color); + } + }} + /> +
+
+
); }; diff --git a/apps/admin/src/tags/detail/tag-detail-form.tsx b/apps/admin/src/tags/detail/tag-detail-form.tsx index 4e33c215ebf..a2f619458dc 100644 --- a/apps/admin/src/tags/detail/tag-detail-form.tsx +++ b/apps/admin/src/tags/detail/tag-detail-form.tsx @@ -1,8 +1,9 @@ import React from 'react'; import TagColorField from './tag-color-field'; +import TagCodeInjectionAccordion from './tag-code-injection-accordion'; import TagImageField from './tag-image-field'; -import {Accordion, AccordionContent, AccordionItem, AccordionTrigger, Card, CardContent, CodeEditor, FieldError, Input, Label, Textarea} from '@tryghost/shade/components'; -import {Stack} from '@tryghost/shade/primitives'; +import {Card, CardContent, FieldError, Input, Label, Tabs, TabsContent, TabsList, TabsTrigger, Textarea} from '@tryghost/shade/components'; +import {Grid, Inline, Stack} from '@tryghost/shade/primitives'; import {DESCRIPTION_MAX_LENGTH, FACEBOOK_DESCRIPTION_RECOMMENDED_LENGTH, FACEBOOK_TITLE_RECOMMENDED_LENGTH, META_DESCRIPTION_RECOMMENDED_LENGTH, META_TITLE_RECOMMENDED_LENGTH, X_DESCRIPTION_RECOMMENDED_LENGTH, X_TITLE_RECOMMENDED_LENGTH, charLength, getBlogDomain, getSeoDescription, getSeoTitle, getSeoUrl, getSlugUrlPreview, validateTagField} from './tag-detail-edit'; import {FacebookCardPreview, SeoPreview, XCardPreview} from './tag-detail-previews'; import {cn, formatNumber} from '@tryghost/shade/utils'; @@ -21,28 +22,17 @@ interface TagDetailFormProps { } const errorId = (field: TagFieldName) => `tag-${field}-error`; -const htmlExtensions = [() => import('@codemirror/lang-html').then(module => module.html())]; - /** Ember's `gh-count-down-characters`: the used count, red once past the limit. */ const UsedCharacters: React.FC<{value: string; limit: number; prefix: 'Maximum' | 'Recommended'}> = ({value, limit, prefix}) => { const used = charLength(value); return (

- {prefix}: {formatNumber(limit)} characters. You’ve used{' '} + {prefix}: {formatNumber(limit)} characters. You’ve used{' '} limit ? 'text-destructive' : 'text-state-success')}>{formatNumber(used)}

); }; -const SectionTrigger: React.FC<{title: string; description: string}> = ({title, description}) => ( - - - {title} - {description} - - -); - const TagDetailForm: React.FC = ({draft, errors, blogUrl, disabled, onChange, onFieldError, onImageBusyChange, onImageUploadPendingChange}) => { const {data: settingsData} = useBrowseSettings({}); const siteTitle = getSettingValue(settingsData?.settings ?? [], 'title') ?? ''; @@ -61,15 +51,16 @@ const TagDetailForm: React.FC = ({draft, errors, blogUrl, di const blogDomain = getBlogDomain(blogUrl); return ( -
- {/* Card 1 mirrors Ember's main form block; card 2 groups the - collapsible sections — the member detail screen's card idiom. */} - - -
-
-
-
+ + {/* The main form and advanced settings collapse into one column + below the medium breakpoint. */} + + + + + + + = ({draft, errors, blogUrl, di onBlur={() => validateOnBlur('name')} onChange={e => onChange({name: e.target.value})} /> -
+ = ({draft, errors, blogUrl, di onChange={accentColor => onChange({accentColor})} onError={message => onFieldError('accentColor', message)} /> -
-
+ + {errors.name} {errors.accentColor}

Start with # to create internal tags.{' '} Learn more

-
- -
- - validateOnBlur('slug')} - onChange={e => onChange({slug: e.target.value})} - /> -

{getSlugUrlPreview(draft.slug, blogUrl)}

- {errors.slug} -
- -
- -