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
39 changes: 39 additions & 0 deletions .dependency-cruiser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,45 @@ module.exports = {
to: { path: '^@tryghost/admin($|/)|^apps/admin/' },
},
// ============================================================
// apps/admin — domains cross into each other only via api.ts
// ============================================================
{
name: 'admin-domains-cross-via-api-only',
comment:
"A domain folder in apps/admin/src may import a different domain only through that domain's public surface (its api.ts). Deep imports couple domains to each other's internals. In-app imports use the @/ alias, which the cruiser sees as an unresolved @/-prefixed specifier; both that shape and resolved relative paths are matched. Test files are exempt.",
severity: 'error',
from: {
path: '^apps/admin/src/(members|settings|analytics|posts|tags|comments|automations|onboarding|whats-new)/',
pathNot: ['\\.test\\.(ts|tsx)$'],
},
to: {
path: '^(?:@/|apps/admin/src/)(?:members|settings|analytics|posts|tags|comments|automations|onboarding|whats-new)($|/)',
pathNot: [
'^(?:@/|apps/admin/src/)$1($|/)',
'^(?:@/|apps/admin/src/)(?:members|settings|analytics|posts|tags|comments|automations|onboarding|whats-new)/api(\\.ts)?$',
],
},
},
// ============================================================
// apps/admin — the shell and layout import domains only via api.ts
// ============================================================
{
name: 'admin-shell-into-domains-via-api-only',
comment:
'The admin shell (top-level files in apps/admin/src plus its non-domain support folders) may import a domain only through its api.ts. Same matching notes as admin-domains-cross-via-api-only. Test files are exempt.',
severity: 'error',
from: {
path: '^apps/admin/src/(?:(?:layout|hooks|providers|ember-bridge|utils|schemas)/.+|[^/]+\\.(?:ts|tsx))$',
pathNot: ['\\.test\\.(ts|tsx)$'],
},
to: {
path: '^(?:@/|apps/admin/src/)(?:members|settings|analytics|posts|tags|comments|automations|onboarding|whats-new)($|/)',
pathNot: [
'^(?:@/|apps/admin/src/)(?:members|settings|analytics|posts|tags|comments|automations|onboarding|whats-new)/api(\\.ts)?$',
],
},
},
// ============================================================
// apps/admin — shared/ must stay domain-free
// ============================================================
{
Expand Down
94 changes: 69 additions & 25 deletions apps/admin-x-framework/src/api/member-custom-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,31 +191,63 @@ const isPartRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);

/**
* How each composite type reads as one line. Written per type rather than walked from
* `subFieldsOf`, because where a part sits in the sentence is a fact about how the value
* reads, not one the value schema can supply — an address fuses state and postal code the
* way people write them. A part added upstream stays out of the line until someone decides
* where it belongs.
* Parts that read as one run rather than as separate items — "NY 00001", not "NY, 00001".
*
* Total over the field types, the way the presentation catalog above is: a type added
* upstream fails to compile here until someone has decided how its value reads, rather
* than reaching every surface as a blank cell. A scalar declares `undefined`, which is
* how "its value is already a line" is said.
* This is the whole of what a composite's one-line form needs stated. Everything else
* comes from the value schema's declaration order, so a part added to a type upstream
* appears in the line on its own, without anyone knowing to come here. That was the point:
* the previous version wrote each type's line out by hand, and a part left out of it was
* collected, stored, exported and filtered on while being invisible in every summary —
* a silent omission, which is the worst way for this to fail.
*
* Typed against the parts each type declares, so renaming or removing one upstream fails
* the build here. Deliberately not exhaustive: a part nobody mentions is one that reads
* perfectly well on its own, and requiring an entry for each would put the omission
* problem straight back.
*/
const compositeValueFormatters: {
[T in FieldType]: [PartsOf<T>] extends [never]
? undefined
: (value: Record<string, unknown>) => string;
} = {
short_text: undefined,
long_text: undefined,
address: (value) => {
const { line1, line2, city, state, postal_code: postalCode, country } = value;
const statePostal = [state, postalCode].filter(Boolean).join(' ');
return [line1, line2, city, statePostal, country].filter(Boolean).join(', ');
},
export type CompositePartRuns = { [T in FieldType]?: ReadonlyArray<readonly PartsOf<T>[]> };

const fusedParts: CompositePartRuns = {
address: [['state', 'postal_code']],
};

/**
* A composite type's parts grouped into the runs its line is built from: declaration
* order, with anything fused above kept together.
*
* A fused pair that is not adjacent in declaration order simply reads as two runs, so
* reordering a type upstream costs a comma rather than a wrong sentence.
*/
function partRunsFor(type: FieldType): string[][] {
const parts: string[] | null = subFieldsOf(type);
if (!parts) {
return [];
}

const runOf = new Map<string, number>();
((fusedParts[type] ?? []) as ReadonlyArray<readonly string[]>).forEach((group, index) => {
group.forEach((part) => runOf.set(part, index));
});

const runs: string[][] = [];
let openRun: number | undefined;
for (const part of parts) {
const run = runOf.get(part);
if (run !== undefined && run === openRun) {
runs[runs.length - 1].push(part);
continue;
}
runs.push([part]);
openRun = run;
}
return runs;
}

// Resolved once: the catalog is static, and this is read for every row of a member list.
const partRuns = Object.fromEntries(
FIELD_TYPE_IDS.map((type) => [type, partRunsFor(type)]),
) as Record<FieldType, string[][]>;

/**
* A member's value for one field as a single readable line: the string itself for a
* scalar, and for a composite its parts joined the way that type reads — e.g.
Expand All @@ -228,13 +260,25 @@ const compositeValueFormatters: {
* table cell than in a detail row.
*/
export const formatMemberCustomFieldValue = (type: FieldType, value: unknown): string => {
const formatComposite = compositeValueFormatters[type];
// Null for a scalar, and for a type this build has never heard of — both of which read
// as text or as nothing.
if (subFieldsOf(type) === null) {
return typeof value === 'string' ? value : '';
}

if (formatComposite) {
return isPartRecord(value) ? formatComposite(value) : '';
if (!isPartRecord(value)) {
return '';
}

return typeof value === 'string' ? value : '';
return (partRuns[type] ?? [])
.map((run) =>
run
.map((part) => value[part])
.filter((part): part is string => typeof part === 'string' && part !== '')
.join(' '),
)
.filter(Boolean)
.join(', ');
};

export interface MemberCustomFieldsResponseType {
Expand Down
45 changes: 43 additions & 2 deletions apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type CompositePartRuns,
type FieldTypePresentation,
type MemberCustomField,
formatMemberCustomFieldValue,
Expand Down Expand Up @@ -41,7 +42,27 @@ const scalarWithParts: FieldTypePresentation<'short_text'> = {
subFields: { line1: 'a' },
};

export { labelled, missingPart, unknownPart, unlabelled, scalarWithParts };
// A run is how the one-line form is told which parts read together. It names parts rather
// than listing them all, so a part *added* upstream needs no entry — but a part renamed or
// removed upstream has to be caught, or the run would silently stop fusing anything.
const fusedRun: CompositePartRuns = { address: [['state', 'postal_code']] };

// @ts-expect-error a run naming a part its value schema does not declare
const unknownFusedPart: CompositePartRuns = { address: [['state', 'postcode']] };

// @ts-expect-error a scalar type has no parts to run together
const scalarWithRun: CompositePartRuns = { short_text: [['line1']] };

export {
labelled,
missingPart,
unknownPart,
unlabelled,
scalarWithParts,
fusedRun,
unknownFusedPart,
scalarWithRun,
};

const field = (overrides: Partial<MemberCustomField>): MemberCustomField => ({
key: 'nickname',
Expand Down Expand Up @@ -122,7 +143,9 @@ describe('member custom fields api helpers', () => {
field({ key: 'address_home', name: 'Address (Home)', type: 'address' }),
]);

expect(columns[2]).toEqual({
// Found rather than indexed: what this is about is the label, not the position
// of a part in the address.
expect(columns.find((column) => column.partLabel === 'City')).toEqual({
label: 'Address (Home) (City)',
fieldName: 'Address (Home)',
partLabel: 'City',
Expand Down Expand Up @@ -194,6 +217,24 @@ describe('member custom fields api helpers', () => {
).toBe('1 Main St, 12 apt B, New York, NY 00001, US');
});

// The property this is built for. A part added to a type upstream has to appear in
// the line on its own, because the alternative is a value that is collected, stored,
// exported and filtered on while being invisible in every summary. Asserted through
// the catalog rather than against a hardcoded list, so it keeps holding as the
// catalog grows.
it('includes every part a type declares, in the order it declares them', () => {
const parts = memberCustomFieldParts('address')!;
const value = Object.fromEntries(parts.map(({ key }) => [key, key]));

const line = formatMemberCustomFieldValue('address', value);

for (const { key } of parts) {
expect(line, `${key} is missing from the line`).toContain(key);
}
// Separators aside, the parts read in the order the value schema declares them.
expect(line.split(/,\s|\s/)).toEqual(parts.map(({ key }) => key));
});

it('pairs state and postal code, and drops missing parts cleanly', () => {
expect(
formatMemberCustomFieldValue('address', {
Expand Down
9 changes: 9 additions & 0 deletions apps/admin/src/automations/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Public surface of the automations domain, consumed by the admin shell
* (apps/admin/src/routes.tsx). Everything else in this domain is internal.
*/

// Lazy entries, not component re-exports: the shell mounts these behind
// `lazy:`, so static re-exports would pull the chunks into the shell bundle.
export const lazyAutomationsScreen = () => import('./automations');
export const lazyAutomationEditorScreen = () => import('./editor');
8 changes: 8 additions & 0 deletions apps/admin/src/comments/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Public surface of the comments domain, consumed by the admin shell
* (apps/admin/src/routes.tsx). Everything else in this domain is internal.
*/

// Lazy entry, not a component re-export: the shell mounts it behind `lazy:`,
// so a static re-export would pull the chunk into the shell bundle.
export const lazyCommentsScreen = () => import('./comments');
2 changes: 1 addition & 1 deletion apps/admin/src/comments/components/comment-likes-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
useBrowseCommentLikes,
} from '@tryghost/admin-x-framework/api/comments';
import { LucideIcon, formatNumber, formatTimestamp } from '@tryghost/shade/utils';
import { formatMemberName, memberAvatarProps } from '@/members/member-format';
import { formatMemberName, memberAvatarProps } from '@/members/api';

type DefaultTab = 'likes' | 'dislikes';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from '@tryghost/shade/components';
import { type Comment, useBrowseCommentReports } from '@tryghost/admin-x-framework/api/comments';
import { LucideIcon, formatTimestamp } from '@tryghost/shade/utils';
import { formatMemberName, memberAvatarProps } from '@/members/member-format';
import { formatMemberName, memberAvatarProps } from '@/members/api';

interface CommentReportsModalProps {
comment: Comment;
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/src/comments/components/comment-thread-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { CommentHeader } from './comment-header';
import { CommentMenu } from './comment-menu';
import { CommentMetrics } from './comment-metrics';
import { buildThreadLink } from './thread-link';
import { memberAvatarProps } from '@/members/member-format';
import { memberAvatarProps } from '@/members/api';
import { Link, useSearchParams } from '@tryghost/admin-x-framework';
import { LucideIcon, cn } from '@tryghost/shade/utils';

Expand Down
2 changes: 1 addition & 1 deletion apps/admin/src/comments/components/comments-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { CommentHeader } from './comment-header';
import { CommentMenu } from './comment-menu';
import { CommentMetrics } from './comment-metrics';
import { buildThreadLink } from './thread-link';
import { memberAvatarProps } from '@/members/member-format';
import { memberAvatarProps } from '@/members/api';
import { Link, useSearchParams } from '@tryghost/admin-x-framework';
import {
LoadMoreButton,
Expand Down
3 changes: 2 additions & 1 deletion apps/admin/src/gift-link-modal-host.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Suspense, lazy, useEffect, useState } from 'react';
import { EmberFallback, subscribeOpenGiftLinkModal } from './ember-bridge';
import type { OpenGiftLinkModalEvent } from './ember-bridge';
import { lazyGiftLinkModal } from './posts/api';

// The gift-link modal is React-owned but triggered from the Ember posts/pages
// list. It's only needed once someone opens it, so lazy-load it rather than
// pulling the posts bundle into every list view.
const GiftLinkModal = lazy(() => import('./posts/analytics/modals/gift-link-modal'));
const GiftLinkModal = lazy(lazyGiftLinkModal);

/**
* Bridges the Ember posts/pages context menu to the React gift-link modal.
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/src/home-redirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
isContributorUser,
isOwnerUser,
} from '@tryghost/admin-x-framework/api/users';
import { useOnboarding } from '@/onboarding/hooks/use-onboarding';
import { useOnboarding } from '@/onboarding/api';

// Hosted signup lands on `/?firstStart=true`; the checklist only starts for
// owners but every firstStart visit continues to the onboarding route.
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/src/layout/app-sidebar/app-sidebar-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
SidebarMenu,
SidebarMenuItem,
} from '@tryghost/shade/components';
import WhatsNewDialog from '@/whats-new/components/whats-new-dialog';
import { WhatsNewDialog } from '@/whats-new/api';
import { UserMenu } from './user-menu';
import { useSidebarBannerState } from './hooks/use-sidebar-banner-state';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ReactNode } from 'react';
import ThemeErrorsBanner from '@/layout/app-sidebar/theme-errors-banner';
import UpgradeBanner from '@/layout/app-sidebar/upgrade-banner';
import { useAdminSidebarVisibility } from '@/layout/sidebar-visibility';
import WhatsNewBanner from '@/whats-new/components/whats-new-banner';
import { WhatsNewBanner } from '@/whats-new/api';

import { useUpgradeStatus } from './use-upgrade-status';
import { useWhatsNewStatus } from './use-whats-new-status';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useChangelog } from '@/whats-new/hooks/use-changelog';
import { useWhatsNew } from '@/whats-new/hooks/use-whats-new';
import { useChangelog, useWhatsNew } from '@/whats-new/api';

export interface WhatsNewStatus {
showWhatsNewBanner: boolean;
Expand Down
4 changes: 2 additions & 2 deletions apps/admin/src/layout/app-sidebar/shared-views.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useMemo } from 'react';
import { parseAllSharedViewsJSON } from '@/members/shared-views';
import { parseAllSharedViewsJSON } from '@/members/api';
import { getSettingValue, useBrowseSettings } from '@tryghost/admin-x-framework/api/settings';

export type { SharedView } from '@/members/shared-views';
export type { SharedView } from '@/members/api';

export function getColorHex(color: string): string {
const colorMap: Record<string, string> = {
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/src/layout/app-sidebar/user-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { getGhostPaths } from '@tryghost/admin-x-framework/helpers';
import { toast } from 'sonner';
import { type ThemeMode } from '@/hooks/use-theme';
import { useThemeContext } from '@/providers/theme-context';
import { useWhatsNew } from '@/whats-new/hooks/use-whats-new';
import { useWhatsNew } from '@/whats-new/api';
import { useUpgradeStatus } from './hooks/use-upgrade-status';
import { useBrowseSite } from '@tryghost/admin-x-framework/api/site';
import { UserMenuItem } from './user-menu-item';
Expand Down
9 changes: 9 additions & 0 deletions apps/admin/src/members/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Public surface of the members domain, consumed by the admin shell
* (apps/admin/src/routes.tsx), the layout, and other domains. Everything
* else in this domain is internal.
*/
export { membersRouteChildren } from './routes';
export { buildMembersUrl } from './member-route';
export { formatMemberName, getMemberInitials, memberAvatarProps } from './member-format';
export { type SharedView, parseAllSharedViewsJSON } from './shared-views';
22 changes: 22 additions & 0 deletions apps/admin/src/members/routes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { type RouteObject, lazyComponent } from '@tryghost/admin-x-framework';

// The child routes under `/members`. The shell (apps/admin/src/routes.tsx)
// mounts these under the `/members` route node, which carries the access
// handle. `lazy:` is preserved for per-view code-splitting.
export const membersRouteChildren: RouteObject[] = [
{
index: true,
lazy: lazyComponent(() => import('./members')),
},
{
path: 'import',
lazy: lazyComponent(() => import('./members')),
},
{
// Covers both edit (`:member_id`) and create (the sentinel `new`)
// — real member ids are 24-char hex ObjectIds, so they can't
// collide with the literal "new".
path: ':member_id',
lazy: lazyComponent(() => import('./detail/member-detail')),
},
];
11 changes: 11 additions & 0 deletions apps/admin/src/onboarding/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Public surface of the onboarding domain, consumed by the admin shell
* (apps/admin/src/routes.tsx and the home redirect). Everything else in this
* domain is internal.
*/
export { OnboardingRedirect } from './onboarding-redirect';
export { useOnboarding } from './hooks/use-onboarding';

// Lazy entry, not a component re-export: the shell mounts it behind `lazy:`,
// so a static re-export would pull the chunk into the shell bundle.
export const lazyOnboardingScreen = () => import('./onboarding-route');
Loading
Loading