Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0719815
Moved email settings out of the settings quarantine (#30090)
9larsons Aug 19, 2026
7dfe210
Removed CSS preprocessor automerge exclusion from Renovate config (#3…
9larsons Aug 19, 2026
5a4de98
Moved site settings out of the settings quarantine (#30111)
9larsons Aug 19, 2026
3dc5bc8
Moved database date handling across Core into shared utilities (#30107)
kevinansfield Aug 19, 2026
a728909
Added explicit Mailgun tracking opt-out (#30105)
kevinansfield Aug 19, 2026
a8504bb
Added icons and visibility controls to navigation (#28368)
JohnONolan Aug 19, 2026
9866fce
Moved shared settings code out of the settings quarantine (#30113)
9larsons Aug 19, 2026
7d15645
🐛 Fixed header card asset exclusions (#30114)
9larsons Aug 19, 2026
0dfe197
Moved advanced settings out of the settings quarantine (#30115)
9larsons Aug 19, 2026
7030d43
Fixed async gift checkout payment routing (#30106)
kevinansfield Aug 19, 2026
9b57105
Cleaned up the admin-x naming left in the settings shell (#30117)
9larsons Aug 19, 2026
23579b3
Changed member list columns to carry their own value reader
rob-ghost Aug 18, 2026
22c59dd
Added a members list column for each custom field filtered on
renatoworks Aug 18, 2026
98a8f32
Fixed stale automation stats after route changes (#30071)
troyciesco Aug 19, 2026
1fbe155
Added an unsaved-changes guard for back/forward navigation in setting…
9larsons Aug 19, 2026
63db838
Added basic CSV import file processing (#30110)
PaulAdamDavis Aug 19, 2026
3d142fc
Added backend support for delivering gift subscriptions by email (#29…
kevinansfield Aug 19, 2026
aa34c1b
Added per-task timing logs to server shutdown (#30121)
acburdine Aug 19, 2026
03c62e4
Changed sidebar links to React-owned routes to navigate through the r…
9larsons Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
42 changes: 2 additions & 40 deletions .github/renovate.json5
Original file line number Diff line number Diff line change
Expand Up @@ -514,44 +514,6 @@
"automergeType": "pr"
},

// The catalog rule above is local, so it is evaluated after package
// rules inherited from the shared preset. Repeat the shared CSS/style
// exclusion locally so adding catalog support does not turn intentionally
// human-reviewed build-pipeline updates into automerges, and label every
// such PR so it is easy to find.
{
"description": "Require human review for CSS preprocessor updates",
"matchPackageNames": [
"/^postcss/",
"/^css/",
"/^sass/",
"/^less/",
"/^styl/",
"autoprefixer",
"cssnano",
"postcss",
"postcss-cli",
"postcss-import",
"postcss-custom-media",
"postcss-custom-properties",
"postcss-color-mod-function",
"postcss-easy-import",
"ember-cli-postcss",
"gulp-postcss",
"sass",
"node-sass",
"sass-embedded",
"less",
"stylus",
"stylelint",
"tailwindcss"
],
"automerge": false,
"addLabels": [
"needs:review"
]
},

// Overrides change transitive resolution across the entire workspace,
// so keep them manual and make that policy explicit on their PRs.
{
Expand Down Expand Up @@ -708,8 +670,8 @@
},

// Security patches and minors automerge after CI passes regardless
// of any other automerge exclusion (e.g. the CSS preprocessor rule
// in the shared preset). Security majors are intentionally left
// of any other automerge exclusion (e.g. the third-party action rule
// above). Security majors are intentionally left
// for human merge — but the `vulnerabilityAlerts` block above
// still opens them without a dashboard-approval tick.
{
Expand Down
55 changes: 55 additions & 0 deletions apps/admin-x-framework/src/api/member-custom-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,61 @@ export const memberCustomFieldParts = <T extends FieldType>(type: T): MemberCust
return partKeys.map(key => ({key, label: labels[key]}));
};

/**
* A value as the record of parts a composite reads from, and nothing otherwise.
*
* A predicate rather than an assertion: the same checks either way, but this one hands
* the narrowing to the compiler rather than overriding it, so a formatter below cannot be
* reached by a value nobody looked at.
*/
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.
*
* 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.
*/
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(', ');
}
};

/**
* 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.
* "1 Main St, 12 apt B, New York, NY 00001, US". Missing parts drop out, so a partial
* value still reads naturally.
*
* Empty string for a value that is not the shape its type declares: the type decides
* which shape is readable, so a composite reads only from its parts and a scalar only
* from text. Callers own their own placeholder, since "no value" reads differently in a
* table cell than in a detail row.
*/
export const formatMemberCustomFieldValue = (type: FieldType, value: unknown): string => {
const formatComposite = compositeValueFormatters[type];

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

return typeof value === 'string' ? value : '';
};

export interface MemberCustomFieldsResponseType {
meta?: Meta;
members_custom_fields: MemberCustomField[];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {type FieldTypePresentation, type MemberCustomField, memberCustomFieldCsvColumns, memberCustomFieldParts} from '../../../src/api/member-custom-fields';
import {type FieldTypePresentation, type MemberCustomField, formatMemberCustomFieldValue, memberCustomFieldCsvColumns, memberCustomFieldParts} from '../../../src/api/member-custom-fields';

// Compile-time cases: the build failing is the assertion. Each `@ts-expect-error` fails the
// build if the case it names stops being an error. Declared on one line each, because the
Expand Down Expand Up @@ -98,4 +98,40 @@ describe('member custom fields api helpers', () => {
]);
});
});

describe('formatMemberCustomFieldValue', () => {
it('returns a scalar value as it stands', () => {
expect(formatMemberCustomFieldValue('short_text', 'Editor')).toBe('Editor');
expect(formatMemberCustomFieldValue('long_text', 'A longer note')).toBe('A longer note');
});

it('formats a full address as one readable line', () => {
expect(formatMemberCustomFieldValue('address', {line1: '1 Main St', line2: '12 apt B', city: 'New York', state: 'NY', postal_code: '00001', country: 'US'}))
.toBe('1 Main St, 12 apt B, New York, NY 00001, US');
});

it('pairs state and postal code, and drops missing parts cleanly', () => {
expect(formatMemberCustomFieldValue('address', {line1: '1 Main St', city: 'Berlin', postal_code: '10115', country: 'DE'}))
.toBe('1 Main St, Berlin, 10115, DE');
expect(formatMemberCustomFieldValue('address', {city: 'Berlin'})).toBe('Berlin');
});

// A surface renders its own placeholder, so every unreadable value has to reduce to
// the same empty string rather than leaking a raw object or the word "undefined".
it('formats an empty or unreadable value as an empty string', () => {
expect(formatMemberCustomFieldValue('address', {})).toBe('');
expect(formatMemberCustomFieldValue('address', null)).toBe('');
expect(formatMemberCustomFieldValue('address', undefined)).toBe('');
expect(formatMemberCustomFieldValue('address', ['a', 'b'])).toBe('');
// A composite type from a later build: no formatter, so no line.
expect(formatMemberCustomFieldValue('unheard_of' as MemberCustomField['type'], {line1: '1 Main St'})).toBe('');
});

// The type decides which shape is readable, so a value of the other shape is not
// read at all rather than being coerced into a line that misrepresents it.
it('reads only the shape its type declares', () => {
expect(formatMemberCustomFieldValue('address', '1 Main St, Berlin')).toBe('');
expect(formatMemberCustomFieldValue('short_text', {line1: '1 Main St'})).toBe('');
});
});
});
24 changes: 0 additions & 24 deletions apps/admin/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,30 +62,6 @@ export default tseslint.config(
}
}
},
// Legacy quarantine: settings code moved from admin-x-settings, which linted without
// type-aware rules. Overrides die as files migrate out of src/settings/app; no new code there.
{
files: ['src/settings/app/**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/await-thenable': 'off',
'@typescript-eslint/no-base-to-string': 'off',
'@typescript-eslint/no-duplicate-type-constituents': 'off',
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/no-misused-promises': 'off',
'@typescript-eslint/no-redundant-type-constituents': 'off',
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/only-throw-error': 'off',
'@typescript-eslint/require-await': 'off',
'@typescript-eslint/restrict-plus-operands': 'off',
'@typescript-eslint/restrict-template-expressions': 'off',
'react-refresh/only-export-components': 'off'
}
},
{
files: ['src/**/*.{ts,tsx}'],
plugins: {'no-relative-import-paths': noRelativeImportPaths},
Expand Down
5 changes: 5 additions & 0 deletions apps/admin/src/automations/automations.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ describe('Automations', () => {
it('shows free and paid sequences when Stripe is connected', () => {
renderPage();

expect(mockUseBrowseAutomations).toHaveBeenCalledWith({
defaultErrorHandler: false,
refetchOnMount: 'always',
staleTime: 0
});
expect(screen.getByText('Free member welcome flow')).toBeInTheDocument();
expect(screen.getByText('Paid member welcome flow')).toBeInTheDocument();
});
Expand Down
4 changes: 3 additions & 1 deletion apps/admin/src/automations/hooks/use-visible-automations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import type {Config} from '@tryghost/admin-x-framework/api/config';

export const useVisibleAutomations = () => {
const {data, error, isError, isLoading} = useBrowseAutomations({
defaultErrorHandler: false
defaultErrorHandler: false,
refetchOnMount: 'always',
staleTime: 0
});
const {data: settingsData, isLoading: isSettingsLoading} = useBrowseSettings();
const {data: configData, isLoading: isConfigLoading} = useBrowseConfig();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// NOTE: duplicated in apps/admin/src/settings/app/components/settings/membership/member-emails/default-welcome-email-values.ts — the email design modal needs it in both apps until Automations GAs; keep in sync
// NOTE: duplicated in apps/admin/src/settings/membership/member-emails/default-welcome-email-values.ts — the email design modal needs it in both apps until Automations GAs; keep in sync
import type {AutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails';

export type WelcomeEmailType = 'free' | 'paid';
Expand Down
21 changes: 16 additions & 5 deletions apps/admin/src/hooks/use-hash-link-navigation-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ import React from 'react';
* back to the caller to confirm. Links rendered by react-router (marked with
* `data-discover`) are left alone — the router's own blocker guards those.
*/
export function useHashLinkNavigationGuard(when: boolean) {
export function useHashLinkNavigationGuard(when: boolean, onBlocked?: () => void) {
const [blockedHref, setBlockedHref] = React.useState<string | null>(null);
const blockedHrefRef = React.useRef<string | null>(null);
const whenRef = React.useRef(when);
whenRef.current = when;
const onBlockedRef = React.useRef(onBlocked);
onBlockedRef.current = onBlocked;

React.useEffect(() => {
const onClick = (event: MouseEvent) => {
Expand All @@ -34,7 +37,10 @@ export function useHashLinkNavigationGuard(when: boolean) {
}
event.preventDefault();
event.stopPropagation();
setBlockedHref(anchor.getAttribute('href'));
const href = anchor.getAttribute('href');
blockedHrefRef.current = href;
onBlockedRef.current?.();
setBlockedHref(href);
};

document.addEventListener('click', onClick, true);
Expand All @@ -46,12 +52,17 @@ export function useHashLinkNavigationGuard(when: boolean) {
isBlocked: blockedHref !== null,
/** Confirm leaving: performs the intercepted navigation for real. */
proceed: () => {
if (blockedHref) {
const href = blockedHrefRef.current;
if (href) {
blockedHrefRef.current = null;
setBlockedHref(null);
window.location.hash = blockedHref;
window.location.hash = href;
}
},
/** Cancel leaving: drops the intercepted navigation. */
reset: () => setBlockedHref(null)
reset: () => {
blockedHrefRef.current = null;
setBlockedHref(null);
}
};
}
13 changes: 7 additions & 6 deletions apps/admin/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@
height: 100%;
}

/* Keep admin-x heading line-height consistent with pre-migration settings render. */
.admin-x-base h1,
.admin-x-base h2,
.admin-x-base h3,
.admin-x-base h4,
.admin-x-base h5 {
/* Settings' raw h1-h5 still expect the 1.25em admin-x heading line-height; Shade's base gives 1.5.
Remove once those headings carry an explicit leading. */
.settings-app h1,
.settings-app h2,
.settings-app h3,
.settings-app h4,
.settings-app h5 {
line-height: 1.25em;
}

Expand Down
36 changes: 26 additions & 10 deletions apps/admin/src/layout/app-sidebar/nav-menu-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import React from 'react';
import {Button, SidebarMenuButton, SidebarMenuItem, useSidebar} from '@tryghost/shade/components';
import {cn, LucideIcon} from '@tryghost/shade/utils';
import { useIsActiveLink } from './use-is-active-link';
import { Link } from '@tryghost/admin-x-framework';
import { isEmberOwnedRoute } from '@/routes';

function NavMenuItem({ children, ...props }: React.ComponentProps<typeof SidebarMenuItem>) {
return (
Expand Down Expand Up @@ -114,10 +116,12 @@ function NavMenuLink({
children,
...props
}: NavMenuLinkProps) {
const href = `#/${to?.replace(/^\/?#\//, '')}`;
const path = `/${to?.replace(/^\/?#\//, '')}`;
const computedIsActive = useIsActiveLink({ path: to, activeOnSubpath });
const isActive = controlledIsActive !== undefined ? controlledIsActive : computedIsActive;
const { isMobile, setOpenMobile } = useSidebar();
const isExternal = target === '_blank';
const useRouterLink = !isExternal && !isEmberOwnedRoute(path.split('?')[0]);

const handleClick = () => {
if (isMobile) {
Expand All @@ -130,15 +134,27 @@ function NavMenuLink({
isActive={isActive}
asChild
{...props}>
<a
aria-current={isActive ? 'page' : undefined}
href={target === '_blank' ? to : href}
rel={target === '_blank' ? rel ?? 'noopener noreferrer' : rel}
target={target}
onClick={handleClick}
>
{children}
</a>
{useRouterLink ? (
<Link
aria-current={isActive ? 'page' : undefined}
rel={rel}
target={target}
to={path}
onClick={handleClick}
>
{children}
</Link>
) : (
<a
aria-current={isActive ? 'page' : undefined}
href={isExternal ? to : `#${path}`}
rel={isExternal ? rel ?? 'noopener noreferrer' : rel}
target={target}
onClick={handleClick}
>
{children}
</a>
)}
</SidebarMenuButton>
)
}
Expand Down
16 changes: 16 additions & 0 deletions apps/admin/src/layout/sidebar.acceptance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ describe("Sidebar navigation", () => {
await expect.element(sidebarScreen.navLink("Tags")).not.toHaveAttribute("aria-current");
});

it("uses router navigation for React-owned routes and hash anchors for Ember-owned ones", async () => {
fakeTags([]);
await renderAdminApp("/site");

// Router links carry the router's history state (the unsaved-changes
// blockers rely on it); Ember's router only follows hashchange, so its
// links must stay native anchors.
await sidebarScreen.navLink("Tags").click();
await expect.poll(currentRoute).toBe("/tags");
expect(typeof (window.history.state as {key?: unknown} | null)?.key).toBe("string");

await sidebarScreen.navLink("Posts").click();
await expect.poll(currentRoute).toBe("/posts");
expect((window.history.state as {key?: unknown} | null)?.key).toBeUndefined();
});

it("clicking Posts and Pages navigates to the Ember-owned lists", async () => {
// Posts/Pages active states come from the Ember routing bridge, absent in this tier.
await renderAdminApp("/site");
Expand Down
5 changes: 3 additions & 2 deletions apps/admin/src/members/components/members-filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
toOfferFilterDisplayValues,
useMemberFilterFields
} from '@/members/use-member-filter-fields';
import {CUSTOM_FIELDS_PREFIX} from '@/members/member-fields';
import {getSettingValue, useBrowseSettings} from '@tryghost/admin-x-framework/api/settings';
import {getSiteTimezone} from '@tryghost/admin-x-framework/utils/get-site-timezone';
import {useBrowseNewsletters} from '@tryghost/admin-x-framework/api/newsletters';
Expand Down Expand Up @@ -104,8 +105,8 @@ const MembersFilters: React.FC<MembersFiltersProps> = ({
const referencedCustomFieldKeys = useMemo(() => new Set(
filters
.map(filter => filter.field)
.filter(field => field.startsWith('custom_field.'))
.map(field => field.slice('custom_field.'.length))
.filter(field => field.startsWith(CUSTOM_FIELDS_PREFIX))
.map(field => field.slice(CUSTOM_FIELDS_PREFIX.length))
.filter(Boolean)
), [filters]);
// Only when the current filter references a custom field do we also pull the archived
Expand Down
Loading
Loading