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
2 changes: 1 addition & 1 deletion .github/renovate.json5
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@
// changeset. The first command installs the script's deps — Renovate's
// own install can be lockfile-only, so @internal/scripts' node_modules
// isn't guaranteed present. Both commands must be allowlisted in the
// runner via RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS (see
// runner via RENOVATE_ALLOWED_COMMANDS (see
// .github/workflows/renovate.yml).
{
description: 'Auto-generate changesets for publishable package updates',
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -804,7 +804,7 @@ jobs:
--health-timeout=5s
--health-retries=60
redis:
image: redis:7.4@sha256:e9b2e45ecd47fbb69b877cf8d045d5cccaaaed52524b6e098b4abe8212994f73
image: redis:7.4@sha256:71da9275c5f3fcb97d0fa0c8c5b36cc995327265420f17a04bfd544f458059f7
ports:
- 6379:6379
options: >-
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish-tinybird-local-slim.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jobs:
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4

- name: Login to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
Expand Down
32 changes: 24 additions & 8 deletions .github/workflows/renovate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,19 +111,32 @@ jobs:
PR_CAP=10
fi

# Exclude `needs:review` PRs: Renovate cannot merge them itself, so
# counting them lets a handful of parked updates hold the cap shut
# forever — the cap is only released by Renovate merging something.
open_count=$(gh pr list \
# Count only the PRs Renovate can merge itself. Renovate states that
# per PR in the body ("Automerge: Enabled" / "Disabled by config"),
# which is the only live signal for it — `needs:review` was a proxy
# that missed every other automerge exclusion, letting parked PRs
# hold the cap shut forever. The cap is only released by Renovate
# merging something, so a PR it will never merge must not count.
# A body without the marker counts, so an unparseable PR tightens
# the cap rather than loosening it. `--limit` is well above any real
# backlog for the same reason: a truncated list undercounts, which
# would loosen the cap.
prs=$(gh pr list \
--repo "${{ github.repository }}" \
--author "app/tryghost-renovate" \
--state open \
--limit 100 \
--json number,labels \
--jq '[.[] | select(any(.labels[].name; . == "needs:review") | not)] | length')
--limit 1000 \
--json number,body)

open_count=$(jq '[.[] | select((.body // "") | test("\\*\\*Automerge\\*\\*: Disabled") | not)] | length' <<<"$prs")
parked=$(jq -r '[.[] | select((.body // "") | test("\\*\\*Automerge\\*\\*: Disabled")) | .number | tostring] | join(", ")' <<<"$prs")

echo "Renovate has $open_count open PRs it can merge itself (cap: $PR_CAP)"

if [ -n "$parked" ]; then
echo "Parked PRs excluded from the cap, each needs a human merge: $parked"
fi

if [ "$MAINTENANCE_ONLY" = "true" ]; then
force='{"dependencyDashboardApproval":true,"prCreation":"approval","vulnerabilityAlerts":{"dependencyDashboardApproval":false}}'
echo "::notice::RENOVATE_MAINTENANCE_ONLY=true. Running in maintenance-only mode: existing PRs may update/automerge, new PRs require dashboard approval."
Expand Down Expand Up @@ -165,4 +178,7 @@ jobs:
# Renovate refuses to run any post-upgrade command that isn't matched
# by one of these regexes — arbitrary command execution is opt-in. Both
# the dep install and the generator command must be matched.
RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS: '["^pnpm install --no-frozen-lockfile --filter @internal/scripts --prod --ignore-scripts$", "^node scripts/generate-changeset\\.js$"]'
# `allowedCommands` is the current name; the old
# `allowedPostUpgradeCommands` is silently ignored on Renovate >= 41,
# which left every npm PR with a failed `renovate/artifacts` status.
RENOVATE_ALLOWED_COMMANDS: '["^pnpm install --no-frozen-lockfile --filter @internal/scripts --prod --ignore-scripts$", "^node scripts/generate-changeset\\.js$"]'
1 change: 1 addition & 0 deletions apps/admin-x-framework/src/api/automations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const MAX_AUTOMATION_ACTIONS = 20;
export type Automation = {
id: string;
name: string;
description?: string;
/** @deprecated `slug` will be removed in the future. */
slug?: null | string;
status: AutomationStatus;
Expand Down
3 changes: 2 additions & 1 deletion apps/admin/src/automations/automations.acceptance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe('Automations list', () => {
fakeAutomations([
automation({
name: 'Free member welcome flow',
description: 'Greet new free members.',
slug: 'member-welcome-email-free',
status: 'active',
stats: {
Expand All @@ -45,7 +46,7 @@ describe('Automations list', () => {
await expect.element(automationsScreen.columnHeader('Total entries')).toBeVisible();
await expect.element(automationsScreen.columnHeader('In progress')).toBeVisible();
const row = automationsScreen.rows();
await expect.element(row).toHaveTextContent('Welcome new free members after they sign up.');
await expect.element(row).toHaveTextContent('Greet new free members.');
await expect.element(row).toHaveTextContent('1,432');
await expect.element(row).toHaveTextContent('118');
await expect.element(row).toHaveTextContent('Live');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const automations = [
{
id: 'automation-id-1',
name: 'Free member welcome flow',
description: 'Greet new free members.',
slug: 'member-welcome-email-free',
status: 'active' as const,
stats: {
Expand Down Expand Up @@ -67,12 +68,15 @@ describe('AutomationsList', () => {
vi.useRealTimers();
});

it('renders fetched automations with private beta copy and status labels', () => {
it('renders fetched automations with API and fallback descriptions and status labels', () => {
renderWithRouter(<AutomationsList automations={automations} />);

expect(screen.getAllByTestId('automation-list-row')).toHaveLength(3);
expect(screen.getByText('Free member welcome flow')).toBeInTheDocument();
expect(screen.getByText('Welcome new free members after they sign up.')).toBeInTheDocument();
expect(screen.getByText('Greet new free members.')).toBeInTheDocument();
expect(
screen.queryByText('Welcome new free members after they sign up.'),
).not.toBeInTheDocument();
expect(screen.getByText('Paid member welcome flow')).toBeInTheDocument();
expect(
screen.getByText('Welcome new paid members after they start their subscription.'),
Expand Down
13 changes: 9 additions & 4 deletions apps/admin/src/automations/components/automations-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ const AUTOMATION_DESCRIPTIONS: Record<string, string> = {
'member-welcome-email-paid': 'Welcome new paid members after they start their subscription.',
};

const getAutomationDescription = (automation: AutomationBrowseItem): string | undefined => {
if (automation.description !== undefined) {
return automation.description;
}

return automation.slug ? AUTOMATION_DESCRIPTIONS[automation.slug] : undefined;
};

// Widths are scoped to `lg` because below that the stats lay out on the row's
// grid rather than in table cells, where a fixed width would fight the columns.
const AUTOMATION_STAT_COLUMNS = [
Expand Down Expand Up @@ -137,10 +145,7 @@ const AutomationsList: React.FC<AutomationsListProps> = ({
)}
<TableBody className="flex flex-col lg:table-row-group">
{automations.map((automation) => {
// NOTE: We will soon start reading this description from the API and stop using hard-coded values.
const description = automation.slug
? AUTOMATION_DESCRIPTIONS[automation.slug]
: undefined;
const description = getAutomationDescription(automation);
const lastEntry = automation.stats?.last_run_created_at;
const totalEntries = automation.stats?.total_run_count ?? 0;
const inProgressEntries = automation.stats?.in_progress_run_count ?? 0;
Expand Down
10 changes: 10 additions & 0 deletions apps/portal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@
"cwd": "apps/portal",
"command": "vite build --watch --mode development"
}
},
"lint": {
"dependsOn": [
{
"projects": [
"@tryghost/metafield-types"
],
"target": "build"
}
]
}
},
"tags": [
Expand Down
56 changes: 36 additions & 20 deletions apps/portal/src/components/common/member-custom-fields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,6 @@ const addressLabels = (): Record<keyof Address, string> => ({
country: t('Country'),
});

interface RowProps {
parts: (keyof Address)[];
}

interface FieldProps {
field: DrawableCustomField;
value: CustomFieldValue;
Expand Down Expand Up @@ -69,6 +65,32 @@ function ScalarField({ field, value, errors, onChange, onKeyDown }: FieldProps)
);
}

interface AddressRowProps {
parts: (keyof Address)[];
input: (part: keyof Address) => Record<string, unknown>;
onChange: (part: string | null, value: string) => void;
onKeyDown: (event: React.KeyboardEvent) => void;
}

/**
* One row of an address. Declared here rather than inside the field: a component made
* afresh on every render is a new type each time, so React would replace the row's
* inputs on every keystroke and take the member's focus with them.
*/
function AddressRow({ parts, input, onChange, onKeyDown }: AddressRowProps) {
return (
<div className="gh-portal-input-group-row">
<InputForm
fields={parts.map(input)}
onChange={(event: { target: { value: string } }, changed: { part?: string }) =>
onChange(changed.part ?? null, event.target.value)
}
onKeyDown={onKeyDown}
/>
</div>
);
}

/**
* An address: several inputs drawn as one field, their borders merged so the group reads
* as a single thing.
Expand Down Expand Up @@ -106,26 +128,20 @@ function AddressField({ field, value, errors, onChange, onKeyDown }: FieldProps)
};
};

const Row = ({ parts }: RowProps) => (
<div className="gh-portal-input-group-row">
<InputForm
fields={parts.map(input)}
onChange={(event: { target: { value: string } }, changed: { part?: string }) =>
onChange(field, changed.part ?? null, event.target.value)
}
onKeyDown={onKeyDown}
/>
</div>
);
const rowProps = {
input,
onChange: (part: string | null, changed: string) => onChange(field, part, changed),
onKeyDown,
};

// The form, written out. Nothing else states how an address is laid out, and the
// reasons below read their order from these same rows, so the order a member sees and
// the order they are listed in cannot come apart.
const rows: React.ReactElement<RowProps>[] = [
<Row key="line1" parts={['line1']} />,
<Row key="line2" parts={['line2']} />,
<Row key="city" parts={['city', 'state']} />,
<Row key="postal_code" parts={['postal_code', 'country']} />,
const rows: React.ReactElement<AddressRowProps>[] = [
<AddressRow {...rowProps} key="line1" parts={['line1']} />,
<AddressRow {...rowProps} key="line2" parts={['line2']} />,
<AddressRow {...rowProps} key="city" parts={['city', 'state']} />,
<AddressRow {...rowProps} key="postal_code" parts={['postal_code', 'country']} />,
];

const refused = rows
Expand Down
4 changes: 2 additions & 2 deletions compose.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: ghost-dev

services:
mysql:
image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
image: mysql:8.4.11@sha256:85b9bf2e29cf836ecb8c2a15a935d4ba0c606631dff1dd79531a11983c638f2a
container_name: ghost-dev-mysql
command: --innodb-buffer-pool-size=1G --innodb-log-buffer-size=500M --innodb-change-buffer-max-size=50 --innodb-flush-log-at-trx_commit=0 --innodb-flush-method=O_DIRECT --log-error-verbosity=1
ports:
Expand Down Expand Up @@ -32,7 +32,7 @@ services:
start_period: 10s

redis:
image: redis:7.4@sha256:e9b2e45ecd47fbb69b877cf8d045d5cccaaaed52524b6e098b4abe8212994f73
image: redis:7.4@sha256:71da9275c5f3fcb97d0fa0c8c5b36cc995327265420f17a04bfd544f458059f7
container_name: ghost-dev-redis
command: ['redis-server', '--loglevel', 'warning']
ports:
Expand Down
27 changes: 27 additions & 0 deletions e2e/tests/portal/member-custom-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,33 @@ test.describe('Portal - member custom fields', () => {
}
});

// Typed a key at a time, the way a member fills it in: each keystroke redraws the page,
// and an input rebuilt by that redraw loses focus, swallowing the rest of what is typed.
test('keeps an address part focused while the member types', async ({
page,
browser,
baseURL,
}) => {
const fieldName = `Shipping address ${Date.now()}`;
const member = await createMemberFactory(page.request).create({
name: 'Ada Lovelace',
email: `ada-types-${Date.now()}@ghost.org`,
});
await anAddressFieldMembersMayEdit(page, fieldName);

const { context, profile, key } = await accountSettingsAs(browser, baseURL!, member, fieldName);
try {
const line1 = profile.partInput(key, 'line1');
await line1.click();
await line1.page().keyboard.type('221B Baker Street');

await expect(line1).toBeFocused();
await expect(line1).toHaveValue('221B Baker Street');
} finally {
await context.close();
}
});

// Leaving the page discards what was typed, so a refusal of it has to go too. The
// refusal lives in app state, which outlives the page unless it is cleared, and a
// value the member never sees again would otherwise come back marked as wrong.
Expand Down
8 changes: 4 additions & 4 deletions ghost/core/core/frontend/helpers/foreach.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,17 @@ module.exports = function foreach(items, options) {
items = items.call(this);
}
let visibility = options.hash.visibility;
if (_.isArray(items) && items.length > 0 && checks.isPost(items[0])) {
if (Array.isArray(items) && items.length > 0 && checks.isPost(items[0])) {
visibility = visibility || 'all';
} else if (_.isObject(items) && _.isArray(Object.values(items))) {
} else if (_.isObject(items) && Array.isArray(Object.values(items))) {
if (Object.values(items).length > 0 && checks.isPost(Object.values(items)[0])) {
visibility = visibility || 'all';
}
}

if (_.isArray(items) && items.length > 0 && checks.isNewsletter(items[0])) {
if (Array.isArray(items) && items.length > 0 && checks.isNewsletter(items[0])) {
visibility = visibility || 'all';
} else if (_.isObject(items) && _.isArray(Object.values(items))) {
} else if (_.isObject(items) && Array.isArray(Object.values(items))) {
if (Object.values(items).length > 0 && checks.isNewsletter(Object.values(items)[0])) {
visibility = visibility || 'all';
}
Expand Down
2 changes: 1 addition & 1 deletion ghost/core/core/frontend/services/rendering/templates.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ templates.getEntryTemplateHierarchy = function getEntryTemplateHierarchy(postObj
templates.pickTemplate = function pickTemplate(templateList, fallback) {
let template;

if (!_.isArray(templateList)) {
if (!Array.isArray(templateList)) {
templateList = [templateList];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const iconUrlRegex = new RegExp(/^(\/|__GHOST_URL__\/)/);
const iconUrlOptions = { require_protocol: true, protocols: ['http', 'https'] };

function parseArraySettingValue(value) {
if (_.isArray(value)) {
if (Array.isArray(value)) {
return value;
}

Expand Down Expand Up @@ -163,10 +163,10 @@ module.exports = {
// settings API use raw unstringified objects (e.g. when adding notifications)
// The conditional can be removed once internals are changed to do the calls properly
// and the JSON.parse should be left as the only valid way to check the value.
if (!_.isArray(setting.value)) {
if (!Array.isArray(setting.value)) {
try {
const value = JSON.parse(setting.value);
if (!_.isArray(value)) {
if (!Array.isArray(value)) {
errors.push(typeError);
}
} catch (err) {
Expand Down
3 changes: 1 addition & 2 deletions ghost/core/core/server/data/exporter/exporter.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
const _ = require('lodash');
const db = require('../../data/db');
const commands = require('../schema').commands;
const ghostVersion = require('@tryghost/version');
Expand All @@ -14,7 +13,7 @@ const { TABLES_ALLOWLIST, SETTING_KEYS_BLOCKLIST } = require('./table-lists');
const exportTable = function exportTable(tableName, options) {
if (
TABLES_ALLOWLIST.includes(tableName) ||
(options.include && _.isArray(options.include) && options.include.indexOf(tableName) !== -1)
(options.include && Array.isArray(options.include) && options.include.indexOf(tableName) !== -1)
) {
const query = (options.transacting || db.knex)(tableName);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ class Base {
let problems = [];

// CASE: validation errors, see models/base/events.js onValidate
if (!_.isArray(errs)) {
if (!Array.isArray(errs)) {
errs = [errs];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ class PostsImporter extends BaseImporter {
return;
}

if (!postToImport[target] || !_.isArray(postToImport[target])) {
if (!postToImport[target] || !Array.isArray(postToImport[target])) {
postToImport[target] = [];
}

Expand Down Expand Up @@ -274,7 +274,7 @@ class PostsImporter extends BaseImporter {
try {
mobiledoc = JSON.parse(model.mobiledoc);

if (!mobiledoc.cards || !_.isArray(mobiledoc.cards)) {
if (!mobiledoc.cards || !Array.isArray(mobiledoc.cards)) {
mobiledoc = mobiledocLib.blankDocument;
}
} catch (err) {
Expand Down
Loading
Loading