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 .secretlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"/69010382388f9de5869ad6e558/",
"/process\\.env\\./",
"/this\\._settingsCache\\.get/",
"/this\\.options\\.password/"
"/this\\.options\\.password/",
"/signupDetails\\.password/"
]
},
{
Expand Down
13 changes: 7 additions & 6 deletions apps/admin-x-framework/src/api/members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,10 @@ export type Member = {
};
last_seen_at: string | null;
last_commented_at: string | null;
// Custom field values keyed by field key, present only when requested via
// `include=custom_fields` (behind the `membersCustomFields` flag). Values
// are type-dependent: string for text-backed fields, an object for
// composites like address — hence `unknown`; consumers narrow per field type.
// Values of the custom fields a publisher has defined on member records, keyed by
// field. Optional because a site that has defined none gets no key at all, rather than
// an empty object. Values differ by field type, a string for text and an object for an
// address, so they arrive as unknown and each consumer narrows to what it expects.
custom_fields?: Record<string, unknown>;
can_comment?: boolean;
commenting?: {
Expand Down Expand Up @@ -545,8 +545,9 @@ export interface EditMemberData {
tiers?: Array<{ id: string; expiry_at?: string | null }>;
// Merge semantics: only the keys present are written; `null` clears a
// value. The value union is derived from the shared schemas, so a field type
// added there is writable here without this line being edited. Requires the
// `membersCustomFields` flag server-side.
// added there is writable here without this line being edited. Every key is checked
// against the fields the site has defined, so naming one that does not exist is
// rejected rather than ignored.
custom_fields?: Record<string, FieldValue | null>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface ImportMembersGateProps {
* to one, until the flag goes and the baseline is deleted.
*
* Custom fields are not this flag's decision. The redesign ships whether or not custom fields
* exist, and asks `membersCustomFields` itself for whether to offer them.
* exist.
*/
export function ImportMembersGate(props: ImportMembersGateProps) {
const importRedesignEnabled = useFeatureFlag('membersImportRedesign');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,8 @@ import {
isImportMembersCompleteResponse,
useImportMembers,
} from '@tryghost/admin-x-framework/api/members';
import {
memberCustomFieldCsvColumns,
useBrowseMemberCustomFields,
} from '@tryghost/admin-x-framework/api/member-custom-fields';
import { memberCustomFieldCsvColumns } from '@tryghost/admin-x-framework/api/member-custom-fields';
import { useCustomFieldDefinitions } from '@/shared/member-custom-fields/use-definitions';
import { parseCSV } from '@/members/components/bulk-action-modals/import-members/csv';
import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef } from 'react';
import { useFeatureFlag } from '@tryghost/admin-x-framework/hooks';
Expand All @@ -77,39 +75,24 @@ export function ImportMembersModal({
const { mutateAsync: importMembers } = useImportMembers();
const importMemberTier = useFeatureFlag('importMemberTier');

// Whether custom fields exist at all is their own flag's answer, not this dialog's: the
// redesigned import ships on `membersImportRedesign` and has to be a plain column-to-member-field
// mapper while custom fields are still an experiment. Off, they are absent from every part of
// this file — not fetched, not offered as a target, not creatable.
const customFieldsEnabled = useFeatureFlag('membersCustomFields');
// Defined custom fields become mapping targets. Browse returns active fields only, which
// are the ones the importer writes to.
const { data: customFieldsData, isError: customFieldsFailed } = useBrowseMemberCustomFields({
enabled: customFieldsEnabled,
});
const canCreateCustomFields = useFeatureFlag('membersCustomFields');
const { data: customFieldsData, isError: customFieldsFailed } = useCustomFieldDefinitions();
// A field created from the mapping step is in here the moment it is created: the create
// mutation puts it into the cached list, so there is no window where a row points at a
// column the picker cannot name yet.
//
// The flag is asked again rather than left to the disabled query above: disabling stops the
// fetch, not the read, so a cache another screen had warmed would still be served here.
const customFieldColumns = useMemo(
() =>
customFieldsEnabled
? memberCustomFieldCsvColumns(customFieldsData?.members_custom_fields ?? [])
: [],
[customFieldsEnabled, customFieldsData],
() => memberCustomFieldCsvColumns(customFieldsData?.members_custom_fields ?? []),
[customFieldsData],
);
// The file-reader effect waits for this before its first parse: the custom field
// definitions must be loaded or auto-detection would miss custom_fields.* columns on a
// fast upload. It flips false -> true once and stays true (a refetch keeps data defined),
// so readiness never re-triggers the read.
// Ready, or never going to be. Neither a failed query nor a disabled one has any
// representation in `data`, so waiting on `data` alone leaves the file unparsed and the step
// on a spinner with nothing said — for a query whose only job is to add targets to a list.
// Ready, or never going to be. A failed query has no representation in `data`, so waiting
// on `data` alone leaves the file unparsed and the step on a spinner with nothing said —
// for a query whose only job is to add targets to a list.
// Failing it costs the custom fields; blocking on it costs the import.
const customFieldsReady =
!customFieldsEnabled || customFieldsData !== undefined || customFieldsFailed;
const customFieldsReady = customFieldsData !== undefined || customFieldsFailed;
// Detection options are read inside the effect through this ref rather than as deps, so
// a later refetch of the options can't re-run the read and overwrite a mapping the user
// has begun editing.
Expand Down Expand Up @@ -487,7 +470,7 @@ export function ImportMembersModal({
{(state.status === 'MAPPING' || state.status === 'UPLOADING') &&
state.fileData !== null && (
<MappingStep
canCreateCustomFields={customFieldsEnabled}
canCreateCustomFields={canCreateCustomFields}
dataPreviewIndex={state.dataPreviewIndex}
fileData={state.fileData}
labelPicker={labelPicker}
Expand Down
9 changes: 2 additions & 7 deletions apps/admin/src/members/components/members-filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ import {
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 { useBrowseMemberCustomFieldsIncludingArchived } from '@tryghost/admin-x-framework/api/member-custom-fields';
import { useCustomFieldDefinitionsIncludingArchived } from '@/shared/member-custom-fields/use-definitions';
import type { MemberCustomField } from '@tryghost/admin-x-framework/api/member-custom-fields';
import {
useEmailPostValueSource,
Expand Down Expand Up @@ -135,17 +134,14 @@ const MembersFilters: React.FC<MembersFiltersProps> = ({
const emailValueSource = useEmailPostValueSource();
const labelValueSource = useLabelValueSource();
const { valueSource: tierValueSource, hasMultipleTiers } = useTierValueSource();
const customFieldsEnabled = useFeatureFlag('membersCustomFields');
// The archived-inclusive browse, fetched eagerly: this is the query the hydration gate
// in Members waits on once a filter names a custom field, and a pill reaches the URL on
// the first keystroke — if the gate finds this cache cold it unmounts the whole page to
// a spinner mid-interaction. A field can only be picked after this has answered, so
// fetching it here is what keeps that wait confined to fresh page loads. Archived
// fields ride along so a saved segment on a since-archived field still renders its
// read-only pill.
const { data: customFieldsData } = useBrowseMemberCustomFieldsIncludingArchived({
enabled: customFieldsEnabled,
});
const { data: customFieldsData } = useCustomFieldDefinitionsIncludingArchived();
const catalogCustomFields = customFieldsData?.members_custom_fields ?? EMPTY_CUSTOM_FIELDS;
// The picker offers active fields only.
const customFields = useMemo(
Expand Down Expand Up @@ -181,7 +177,6 @@ const MembersFilters: React.FC<MembersFiltersProps> = ({
emailTrackOpens,
emailTrackClicks,
siteTimezone,
customFieldsEnabled,
customFields,
archivedCustomFields,
});
Expand Down
8 changes: 3 additions & 5 deletions apps/admin/src/members/custom-fields/filter-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@ import { CUSTOM_FIELDS_PREFIX, CUSTOM_FIELD_OPERATORS } from '@/members/member-f
import { CUSTOM_FIELD_SET_OPERATORS } from './addressing';
import { FilterSegmentInput, FilterSegmentSelect } from '@tryghost/shade/patterns';
import { createOperatorOptions, listsOperator } from '@/shared/filters';
import {
memberCustomFieldParts,
useBrowseMemberCustomFieldsIncludingArchived,
} from '@tryghost/admin-x-framework/api/member-custom-fields';
import { memberCustomFieldParts } from '@tryghost/admin-x-framework/api/member-custom-fields';
import { useCustomFieldDefinitionsIncludingArchived } from '@/shared/member-custom-fields/use-definitions';
import type { CustomRendererProps } from '@tryghost/shade/patterns';

const CustomFieldFilterRenderer: React.FC<CustomRendererProps<string>> = ({
Expand All @@ -17,7 +15,7 @@ const CustomFieldFilterRenderer: React.FC<CustomRendererProps<string>> = ({
onOperatorChange,
readOnly,
}) => {
const { data } = useBrowseMemberCustomFieldsIncludingArchived();
const { data } = useCustomFieldDefinitionsIncludingArchived();
const definitions = data?.members_custom_fields ?? [];

const fieldKey = (field.key ?? '').slice(CUSTOM_FIELDS_PREFIX.length);
Expand Down
4 changes: 2 additions & 2 deletions apps/admin/src/members/detail/member-custom-fields-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ import {
import { toast } from 'sonner';
import {
formatMemberCustomFieldValue,
useBrowseMemberCustomFields,
userTypeForField,
} from '@tryghost/admin-x-framework/api/member-custom-fields';
import { useCustomFieldDefinitions } from '@/shared/member-custom-fields/use-definitions';
import { useEditMember } from '@tryghost/admin-x-framework/api/members';
import type { EditableAddressValue, EditableCustomFieldValue } from './member-detail-edit';
import type { MemberCustomField } from '@tryghost/admin-x-framework/api/member-custom-fields';
Expand Down Expand Up @@ -304,7 +304,7 @@ const MemberCustomFieldsField: React.FC<MemberCustomFieldsFieldProps> = ({
customFields,
disabled,
}) => {
const { data, isLoading } = useBrowseMemberCustomFields();
const { data, isLoading } = useCustomFieldDefinitions();
const fields = data?.members_custom_fields ?? [];
const values = getEditableCustomFieldValues(customFields);
const [editingField, setEditingField] = React.useState<MemberCustomField | null>(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import {
} from '@test-utils/acceptance';
import { memberDetailScreen } from './member-detail.screen';

const FLAGS = { labs: { membersCustomFields: true } };

const FIELDS = [
{
key: 'job_title',
Expand Down Expand Up @@ -41,10 +39,8 @@ const ADDRESS = { line1: '1 Main St', city: 'Berlin', postal_code: '10115', coun

/**
* The world the member detail screen reads at mount, plus the custom-fields
* definitions. Values ride the member read payload (`custom_fields`), exactly
* as the API returns them when the membersCustomFields flag is on. The world
* is stateful: a PUT's merge patch is applied (null deletes), so the refetch
* a save triggers returns the saved state.
* definitions. The world is stateful: a PUT's merge patch is applied (null deletes), so
* the refetch a save triggers returns the saved state.
*/
function fakeMemberDetailWorld(m: Member, initialValues: Record<string, unknown>) {
let current: Record<string, unknown> = { ...m };
Expand Down Expand Up @@ -80,7 +76,7 @@ describe('Member detail custom fields', () => {
it('renders the member’s values as a read-only record, addresses as one line', async () => {
const m = member({ name: 'Ada Lovelace' });
fakeMemberDetailWorld(m, { job_title: 'Editor', home_address: ADDRESS });
await renderAdminApp(`/members/${m.id}`, FLAGS);
await renderAdminApp(`/members/${m.id}`);

await expect.element(memberDetailScreen.fieldValue('Editor')).toBeVisible();
await expect
Expand All @@ -95,7 +91,7 @@ describe('Member detail custom fields', () => {
it('saves one field through its own editor without touching the page Save', async () => {
const m = member({ name: 'Ada Lovelace' });
const editApi = fakeMemberDetailWorld(m, { job_title: 'Editor', company: 'Ghost' });
await renderAdminApp(`/members/${m.id}`, FLAGS);
await renderAdminApp(`/members/${m.id}`);

await memberDetailScreen.editFieldButton('Job title').click();
await modal().getByLabelText('Job title').fill('Publisher');
Expand All @@ -114,7 +110,7 @@ describe('Member detail custom fields', () => {
it('leaves an unsaved page edit intact when a custom field is saved', async () => {
const m = member({ name: 'Ada Lovelace' });
const editApi = fakeMemberDetailWorld(m, { job_title: 'Editor' });
await renderAdminApp(`/members/${m.id}`, FLAGS);
await renderAdminApp(`/members/${m.id}`);

// Dirty the page draft by editing the name, without saving it.
await memberDetailScreen.nameInput().fill('Ada L.');
Expand All @@ -139,7 +135,7 @@ describe('Member detail custom fields', () => {
it('clears a value by saving an emptied editor (null merge patch)', async () => {
const m = member({ name: 'Ada Lovelace' });
const editApi = fakeMemberDetailWorld(m, { job_title: 'Editor' });
await renderAdminApp(`/members/${m.id}`, FLAGS);
await renderAdminApp(`/members/${m.id}`);

await memberDetailScreen.editFieldButton('Job title').click();
await modal().getByLabelText('Job title').fill('');
Expand All @@ -156,7 +152,7 @@ describe('Member detail custom fields', () => {
it('a dirty editor refuses casual dismissal; a pristine one closes freely', async () => {
const m = member({ name: 'Ada Lovelace' });
fakeMemberDetailWorld(m, { job_title: 'Editor' });
await renderAdminApp(`/members/${m.id}`, FLAGS);
await renderAdminApp(`/members/${m.id}`);

// Dirty: Escape must NOT close it — typed values can't be lost to a
// stray key or click; Cancel is the one explicit discard.
Expand All @@ -177,7 +173,7 @@ describe('Member detail custom fields', () => {
it('cancelling the editor discards the edit', async () => {
const m = member({ name: 'Ada Lovelace' });
const editApi = fakeMemberDetailWorld(m, { job_title: 'Editor' });
await renderAdminApp(`/members/${m.id}`, FLAGS);
await renderAdminApp(`/members/${m.id}`);

await memberDetailScreen.editFieldButton('Job title').click();
await modal().getByLabelText('Job title').fill('Publisher');
Expand All @@ -190,7 +186,7 @@ describe('Member detail custom fields', () => {
it('saves an address that leaves sub-fields the country does not use empty', async () => {
const m = member({ name: 'Ada Lovelace' });
const editApi = fakeMemberDetailWorld(m, {});
await renderAdminApp(`/members/${m.id}`, FLAGS);
await renderAdminApp(`/members/${m.id}`);

// Hong Kong has no postal code, so leaving it empty is a complete address
// rather than an incomplete one.
Expand All @@ -212,7 +208,7 @@ describe('Member detail custom fields', () => {
it('blocks saving a malformed country code with an inline error, then saves once fixed', async () => {
const m = member({ name: 'Ada Lovelace' });
const editApi = fakeMemberDetailWorld(m, {});
await renderAdminApp(`/members/${m.id}`, FLAGS);
await renderAdminApp(`/members/${m.id}`);

await memberDetailScreen.editFieldButton('Home address').click();
await modal().getByLabelText('Address line 1').fill('1 Main St');
Expand Down Expand Up @@ -256,7 +252,7 @@ describe('Member detail custom fields', () => {
},
{ status: 422 },
);
await renderAdminApp(`/members/${m.id}`, FLAGS);
await renderAdminApp(`/members/${m.id}`);

await memberDetailScreen.editFieldButton('Job title').click();
await modal().getByLabelText('Job title').fill('Editor');
Expand All @@ -278,7 +274,7 @@ describe('Member detail custom fields', () => {
events: [],
meta: { pagination: { page: 1, limit: 5, pages: 1, total: 0, next: null, prev: null } },
});
await renderAdminApp(`/members/${m.id}`, FLAGS);
await renderAdminApp(`/members/${m.id}`);

await expect.element(memberDetailScreen.nameInput()).toBeVisible();
await expect.element(memberDetailScreen.customFieldsSection()).not.toBeInTheDocument();
Expand Down
9 changes: 2 additions & 7 deletions apps/admin/src/members/detail/member-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ import {
import { toast } from 'sonner';
import { useBrowseNewsletters } from '@tryghost/admin-x-framework/api/newsletters';
import { useBrowseTiers } from '@tryghost/admin-x-framework/api/tiers';
import { useFeatureFlag } from '@tryghost/admin-x-framework/hooks';
import { useUnsavedChangesGuard } from '@/hooks/use-unsaved-changes-guard';
import type { MemberEditableFields } from './member-detail-edit';

Expand All @@ -73,14 +72,10 @@ const MemberDetailPage: React.FC<MemberDetailPageProps> = ({
const backPath = deriveMemberDetailBackPath(location.search);
const isCreating = memberId === CREATE_ID;

// Values ride the member payload (`include=custom_fields`) but the include
// only exists behind the flag, so it must not be sent on flag-off sites.
const customFieldsEnabled = useFeatureFlag('membersCustomFields');

// `include=tiers` mirrors the Ember route so complimentary tiers arrive with the member.
const { data, isLoading, error, refetch } = useMember(memberId, {
enabled: !!memberId && !isCreating,
searchParams: { include: customFieldsEnabled ? 'tiers,custom_fields' : 'tiers' },
searchParams: { include: 'tiers' },
defaultErrorHandler: false,
});
const member = data?.members?.[0];
Expand Down Expand Up @@ -469,7 +464,7 @@ const MemberDetailPage: React.FC<MemberDetailPageProps> = ({
modal, never through this page's Save. Existing members
only — the create contract doesn't take values yet, and a
value can't exist before its member does. */}
{customFieldsEnabled && member && (
{member && (
<MemberCustomFieldsField
customFields={member.custom_fields}
disabled={activeMutation.isPending}
Expand Down
Loading
Loading