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
63 changes: 35 additions & 28 deletions apps/admin-x-framework/src/api/member-custom-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Meta, createMutation, createQuery } from '../utils/api/hooks';
// Re-exported so the import mapping can recognize a custom_fields.* column (same reason
// as the re-exports below).
export { isCustomFieldColumn } from '@tryghost/custom-field-types/csv';
export type { FieldIdentity, FieldIdentityString } from '@tryghost/custom-field-types/identity';

// Re-exported so admin apps can type address values and validate against the
// same schemas the server enforces, without a direct dependency on the shared
Expand All @@ -24,7 +25,10 @@ export { FIELD_KINDS as MEMBER_CUSTOM_FIELD_KINDS } from '@tryghost/custom-field
export type { FieldKind as MemberCustomFieldKind } from '@tryghost/custom-field-types';

export type MemberCustomField = {
// Fields are addressed by their immutable key; the DB id is never exposed.
namespace: string;
// The Admin API never serializes a database id for these records, so there is no `id` to
// key off. A field is addressed by its namespace and key, and neither is reissued once
// minted.
key: string;
name: string;
// The same field-type enum the backend validates against, so admin and
Expand Down Expand Up @@ -150,16 +154,18 @@ export const memberCustomFieldCsvColumns = (
): MemberCustomFieldCsvColumn[] => {
return fields.flatMap((field) => {
const labels = partLabelsFor(field.type);
return csvColumnsForField({ key: field.key, type: field.type }).map(({ column, subField }) => {
const partLabel = subField === null ? undefined : labels[subField];
return {
value: column,
fieldName: field.name,
...(partLabel === undefined ? {} : { partLabel }),
label: partLabel === undefined ? field.name : `${field.name} (${partLabel})`,
type: field.type,
};
});
return csvColumnsForField({ namespace: field.namespace, key: field.key, type: field.type }).map(
({ column, subField }) => {
const partLabel = subField === null ? undefined : labels[subField];
return {
value: column,
fieldName: field.name,
...(partLabel === undefined ? {} : { partLabel }),
label: partLabel === undefined ? field.name : `${field.name} (${partLabel})`,
type: field.type,
};
},
);
});
};

Expand Down Expand Up @@ -295,17 +301,18 @@ export const memberCustomFieldKind = (type: FieldType): FieldKind => FIELD_TYPES

export interface MemberCustomFieldsResponseType {
meta?: Meta;
members_custom_fields: MemberCustomField[];
members_metafields: MemberCustomField[];
}

const dataType = 'MemberCustomFieldsResponseType';
// Exported so a screen can move the list in its own cache before the request lands —
// a drag that waits for a round-trip snaps back under the cursor.
export const memberCustomFieldsDataType = dataType;

export const useBrowseMemberCustomFields = createQuery<MemberCustomFieldsResponseType>({
export const useBrowseMemberCustomFields = createQuery<MemberCustomField[]>({
dataType,
path: '/members/custom_fields/',
path: '/members/metafields/custom/',
returnData: (raw) => (raw as MemberCustomFieldsResponseType).members_metafields,
});

// Browse hides archived fields by default. Settings is the one surface that
Expand All @@ -322,8 +329,8 @@ export const useCreateMemberCustomField = createMutation<
Pick<MemberCustomField, 'name' | 'type'>
>({
method: 'POST',
path: () => '/members/custom_fields/',
body: (field) => ({ members_custom_fields: [field] }),
path: () => '/members/metafields/custom/',
body: (field) => ({ members_metafields: [field] }),
invalidateQueries: { dataType },
// The created field is put into the cached lists as well as refetched, so a screen that
// has just made one can use it in the same breath instead of waiting for a round trip or
Expand All @@ -339,13 +346,13 @@ export const useCreateMemberCustomField = createMutation<
emberUpdateType: 'skip',
update: (newData, currentData) => {
const current = currentData as MemberCustomFieldsResponseType | undefined;
if (!current?.members_custom_fields) {
if (!current?.members_metafields) {
return currentData;
}
const created = newData.members_custom_fields.filter(
(field) => !current.members_custom_fields.some((existing) => existing.key === field.key),
const created = newData.members_metafields.filter(
(field) => !current.members_metafields.some((existing) => existing.key === field.key),
);
return { ...current, members_custom_fields: [...current.members_custom_fields, ...created] };
return { ...current, members_metafields: [...current.members_metafields, ...created] };
},
},
});
Expand All @@ -358,8 +365,8 @@ export const useEditMemberCustomField = createMutation<
Pick<MemberCustomField, 'key'> & Partial<Pick<MemberCustomField, 'name' | 'status'>>
>({
method: 'PUT',
path: (field) => `/members/custom_fields/${field.key}/`,
body: ({ key: _key, ...patch }) => ({ members_custom_fields: [patch] }),
path: (field) => `/members/metafields/custom/${field.key}/`,
body: ({ key: _key, ...patch }) => ({ members_metafields: [patch] }),
invalidateQueries: { dataType },
});

Expand All @@ -377,8 +384,8 @@ export const useReorderMemberCustomFields = createMutation<
MemberCustomField[]
>({
method: 'PUT',
path: () => '/members/custom_fields/',
body: (fields) => ({ members_custom_fields: fields.map(({ key }) => ({ key })) }),
path: () => '/members/metafields/custom/',
body: (fields) => ({ members_metafields: fields.map(({ key }) => ({ key })) }),
// The response is the settled order, so it is written straight to the cached lists
// rather than refetched. A reorder only succeeds when it named exactly the fields the
// site has, so a success carries no news about the set — only about its order — and
Expand All @@ -394,13 +401,13 @@ export const useReorderMemberCustomFields = createMutation<
emberUpdateType: 'skip',
update: (newData, currentData) => {
const current = currentData as MemberCustomFieldsResponseType | undefined;
if (!current?.members_custom_fields) {
if (!current?.members_metafields) {
return currentData;
}
const settledOrder = newData.members_custom_fields.map(({ key }) => key);
const settledOrder = newData.members_metafields.map(({ key }) => key);
return {
...current,
members_custom_fields: inOrderOf(settledOrder, current.members_custom_fields),
members_metafields: inOrderOf(settledOrder, current.members_metafields),
};
},
},
Expand Down Expand Up @@ -430,6 +437,6 @@ export const inOrderOf = (
// archiving and reactivating are separate status edits over PUT.
export const useDeleteMemberCustomField = createMutation<void, string>({
method: 'DELETE',
path: (key) => `/members/custom_fields/${key}/`,
path: (key) => `/members/metafields/custom/${key}/`,
invalidateQueries: { dataType },
});
24 changes: 10 additions & 14 deletions apps/admin-x-framework/src/api/members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,11 @@ export type Member = {
};
last_seen_at: string | null;
last_commented_at: string | null;
// 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>;
// The custom-field values a site collects on its members, grouped by the namespace that
// declared each field. Optional because a site with no fields defined gets no key at all
// in the response, not an empty object. Values differ by field typea string for text,
// an object for an address — so consumers narrow per field.
metafields?: Record<string, Record<string, unknown> | undefined>;
can_comment?: boolean;
commenting?: {
disabled: boolean;
Expand Down Expand Up @@ -543,20 +543,16 @@ export interface EditMemberData {
labels?: Array<{ name: string; slug?: string }>;
newsletters?: Array<{ id: string }>;
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. 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>;
// The server applies this as a merge: only the keys present are written, and `null` clears
// a value. Every key is checked against the fields the site has defined; naming one that
// does not exist rejects the edit rather than being ignored.
metafields?: Record<string, Record<string, FieldValue | null>>;
}

export const useEditMember = createMutation<MembersResponseType, EditMemberData>({
method: 'PUT',
path: ({ id }) => `/members/${id}/`,
// `custom_fields` is asked back only when the payload writes it, so the
// request stays valid on sites where the flag (and the include) is off.
searchParams: (payload) => ({ include: payload.custom_fields ? 'tiers,custom_fields' : 'tiers' }),
searchParams: () => ({ include: 'tiers,metafields' }),
body: ({ id, ...rest }) => ({ members: [{ id, ...rest }] }),
invalidateQueries: { dataType },
});
Expand Down
19 changes: 10 additions & 9 deletions apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export {
};

const field = (overrides: Partial<MemberCustomField>): MemberCustomField => ({
namespace: 'custom',
key: 'nickname',
name: 'Nickname',
type: 'short_text',
Expand All @@ -81,7 +82,7 @@ describe('member custom fields api helpers', () => {
{
label: 'Nickname',
fieldName: 'Nickname',
value: 'custom_fields.nickname',
value: 'metafields.custom.nickname',
type: 'short_text',
},
]);
Expand All @@ -97,42 +98,42 @@ describe('member custom fields api helpers', () => {
label: 'Shipping Address (Address line 1)',
fieldName: 'Shipping Address',
partLabel: 'Address line 1',
value: 'custom_fields.shipping_address.line1',
value: 'metafields.custom.shipping_address.line1',
type: 'address',
},
{
label: 'Shipping Address (Address line 2)',
fieldName: 'Shipping Address',
partLabel: 'Address line 2',
value: 'custom_fields.shipping_address.line2',
value: 'metafields.custom.shipping_address.line2',
type: 'address',
},
{
label: 'Shipping Address (City)',
fieldName: 'Shipping Address',
partLabel: 'City',
value: 'custom_fields.shipping_address.city',
value: 'metafields.custom.shipping_address.city',
type: 'address',
},
{
label: 'Shipping Address (State)',
fieldName: 'Shipping Address',
partLabel: 'State',
value: 'custom_fields.shipping_address.state',
value: 'metafields.custom.shipping_address.state',
type: 'address',
},
{
label: 'Shipping Address (Postal code)',
fieldName: 'Shipping Address',
partLabel: 'Postal code',
value: 'custom_fields.shipping_address.postal_code',
value: 'metafields.custom.shipping_address.postal_code',
type: 'address',
},
{
label: 'Shipping Address (Country)',
fieldName: 'Shipping Address',
partLabel: 'Country',
value: 'custom_fields.shipping_address.country',
value: 'metafields.custom.shipping_address.country',
type: 'address',
},
]);
Expand All @@ -149,7 +150,7 @@ describe('member custom fields api helpers', () => {
label: 'Address (Home) (City)',
fieldName: 'Address (Home)',
partLabel: 'City',
value: 'custom_fields.address_home.city',
value: 'metafields.custom.address_home.city',
type: 'address',
});
});
Expand All @@ -171,7 +172,7 @@ describe('member custom fields api helpers', () => {
{
label: 'Mystery',
fieldName: 'Mystery',
value: 'custom_fields.mystery',
value: 'metafields.custom.mystery',
type: 'a_type_from_the_future',
},
]);
Expand Down
6 changes: 4 additions & 2 deletions apps/admin-x-framework/test/unit/api/members.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ describe('members api', () => {
describe('member detail operations', () => {
const apiRoot = 'http://localhost:3000/ghost/api/admin';

it('edits a member with the members envelope and includes tiers', async () => {
it('edits a member with the members envelope and includes tiers and metafields', async () => {
const queryClient = createTestQueryClient();
seedMemberCount(queryClient);

Expand All @@ -439,7 +439,9 @@ describe('members api', () => {
});
});

expect(mock.calls[0][0].toString()).toBe(`${apiRoot}/members/member-1/?include=tiers`);
expect(mock.calls[0][0].toString()).toBe(
`${apiRoot}/members/member-1/?include=${encodeURIComponent('tiers,metafields')}`,
);
expect(mock.calls[0][1].method).toBe('PUT');
expect(JSON.parse(mock.calls[0][1].body)).toEqual({
members: [
Expand Down
2 changes: 2 additions & 0 deletions apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@tryghost/admin-x-framework": "workspace:*",
"@tryghost/checkout": "workspace:*",
"@tryghost/color-utils": "catalog:",
"@tryghost/custom-field-types": "workspace:*",
"@tryghost/custom-fonts": "catalog:",
"@tryghost/i18n": "workspace:*",
"@tryghost/kg-unsplash-selector": "workspace:*",
Expand Down Expand Up @@ -166,6 +167,7 @@
"projects": [
"@tryghost/admin-x-framework",
"@tryghost/checkout",
"@tryghost/custom-field-types",
"@tryghost/shade"
],
"target": "build"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,14 @@ describe('csv helpers', () => {
email: 'b@example.com',
labels: [],
newsletters: [{ name: 'Daily News' }],
'custom_fields.topic': 'ghosts',
'metafields.custom.topic': 'ghosts',
error: 'nope',
},
]);

const header = output.split('\n')[0].trimEnd();
expect(header).toContain('"newsletters"');
expect(header).toContain('"custom_fields.topic"');
expect(header).toContain('"metafields.custom.topic"');
expect(header.endsWith('"error"')).toBe(true);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isCustomFieldColumn } from '@tryghost/admin-x-framework/api/member-custom-fields';
import Papa from 'papaparse';
import { z } from 'zod';

Expand Down Expand Up @@ -88,7 +89,7 @@ function toExportErrorRow(row: RawErrorRow): Record<string, string> {
}

for (const [key, value] of Object.entries(row)) {
if (key.startsWith('custom_fields.')) {
if (isCustomFieldColumn(key)) {
shaped[key] = cell(value);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const membershipFields = [
const customColumn = (
overrides: Partial<MemberCustomFieldCsvColumn> = {},
): MemberCustomFieldCsvColumn => ({
value: 'custom_fields.nickname',
value: 'metafields.custom.nickname',
fieldName: 'Nickname',
label: 'Nickname',
type: 'short_text',
Expand Down Expand Up @@ -45,7 +45,7 @@ describe('field targets', () => {
membershipFields: [],
customFieldColumns: [
customColumn({
value: 'custom_fields.shipping_address.city',
value: 'metafields.custom.shipping_address.city',
fieldName: 'Shipping Address',
partLabel: 'City',
label: 'Shipping Address (City)',
Expand All @@ -56,7 +56,7 @@ describe('field targets', () => {

expect(allTargets(groups)).toEqual([
{
value: 'custom_fields.shipping_address.city',
value: 'metafields.custom.shipping_address.city',
source: 'custom',
fieldName: 'Shipping Address',
partLabel: 'City',
Expand Down Expand Up @@ -139,7 +139,7 @@ describe('field targets', () => {
membershipFields,
customFieldColumns: [
customColumn({
value: 'custom_fields.name.city',
value: 'metafields.custom.name.city',
fieldName: 'Name',
partLabel: 'City',
label: 'Name (City)',
Expand All @@ -153,7 +153,7 @@ describe('field targets', () => {

it('marks only against the targets in the list it is given', () => {
const custom = [
customColumn({ value: 'custom_fields.tier', fieldName: 'Tier', label: 'Tier' }),
customColumn({ value: 'metafields.custom.tier', fieldName: 'Tier', label: 'Tier' }),
];

expect(badged(fieldTargets({ membershipFields, customFieldColumns: custom }))).toEqual([]);
Expand Down
Loading
Loading