Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .agents/skills/shade-component-decision/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion .agents/skills/shade-dropdown-surface-contract/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion .agents/skills/shade-new-component/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion .agents/skills/shade-page-templates/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion .agents/skills/shade-shadcn-install/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
15 changes: 12 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -191,15 +194,17 @@ 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:
- 'ghost/core/vitest.config.ts'
- 'ghost/core/test/utils/vitest-*.ts'
any-code:
- '!**/*.md'
- '!**/*.mdx'
- '!.devcontainer/**'
- '!.vscode/**'
- *renovate_only
Expand All @@ -211,6 +216,7 @@ jobs:
# added here as their conventions are confirmed.
e2e:
- '!**/*.md'
- '!**/*.mdx'
- '!.devcontainer/**'
- '!.vscode/**'
- '!ghost/core/test/**'
Expand Down Expand Up @@ -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
Expand Down
26 changes: 25 additions & 1 deletion .lintstagedrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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'
};
29 changes: 28 additions & 1 deletion apps/admin/src/members/components/members-filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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[],
Expand Down Expand Up @@ -93,6 +97,26 @@ const MembersFilters: React.FC<MembersFiltersProps> = ({
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,
Expand All @@ -109,7 +133,10 @@ const MembersFilters: React.FC<MembersFiltersProps> = ({
membersTrackSources,
emailTrackOpens,
emailTrackClicks,
siteTimezone
siteTimezone,
customFieldsEnabled,
customFields,
archivedCustomFields
});

const hasFilters = filters.length > 0;
Expand Down
97 changes: 97 additions & 0 deletions apps/admin/src/members/custom-field-filter-renderer.tsx
Original file line number Diff line number Diff line change
@@ -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.<key>`), 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<CustomRendererProps<string>> = ({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 && (
<FilterSegmentSelect
ariaLabel={`${fieldLabel} part`}
options={partOptions}
readOnly={readOnly}
testId="custom-field-filter-subfield"
value={subfield}
onChange={nextSubfield => onChange([nextSubfield, value])}
/>
)}

{onOperatorChange && (
<FilterSegmentSelect
ariaLabel={`${fieldLabel} operator`}
options={createOperatorOptions(operators)}
readOnly={readOnly}
testId="custom-field-filter-operator"
value={operator}
onChange={onOperatorChange}
/>
)}

{needsValue && (
<FilterSegmentInput
ariaLabel={`${fieldLabel} value`}
placeholder="Enter value..."
readOnly={readOnly}
testId="custom-field-filter-value"
value={value}
onChange={nextValue => onChange([subfield, nextValue])}
/>
)}
</>
);
};

export default CustomFieldFilterRenderer;
3 changes: 2 additions & 1 deletion apps/admin/src/members/member-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
]);
});

Expand Down
86 changes: 86 additions & 0 deletions apps/admin/src/members/member-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<subfield>`
// (address), which the members filter relation maps onto the real columns.
const CUSTOM_FIELD_VALUE_SYMBOLS: Record<string, string> = {
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.<key>`,
// 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,
Expand Down Expand Up @@ -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.<key>`), 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
}
});

Expand Down
Loading
Loading