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: 2 additions & 0 deletions apps/admin-x-framework/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"glob": "catalog:",
"jsdom": "catalog:",
"msw": "catalog:",
"type-fest": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-css-injected-by-js": "3.5.2",
Expand All @@ -102,6 +103,7 @@
"@tinybirdco/charts": "0.2.4",
"@tryghost/admin-x-design-system": "workspace:*",
"@tryghost/shade": "workspace:*",
"bson-objectid": "catalog:",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hot-toast": "2.6.0",
Expand Down
112 changes: 112 additions & 0 deletions apps/admin-x-framework/src/api/automations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import ObjectId from 'bson-objectid';
import {Meta, createMutation, createQuery, createQueryWithId} from '../utils/api/hooks';
import type {ReadonlyDeep} from 'type-fest';

export type AutomationStatus = 'active' | 'inactive';
export const MAX_AUTOMATION_ACTIONS = 20;

export type Automation = {
id: string;
Expand Down Expand Up @@ -86,3 +89,112 @@ export const useEditAutomation = createMutation<AutomationDetailResponseType, Ed
dataType
}
});

const generateActionId = (): string => ObjectId().toHexString();

// TODO NY-1253: replace this placeholder when email content can be edited.
const PLACEHOLDER_EMAIL_LEXICAL = JSON.stringify({
root: {
children: [{
type: 'paragraph',
children: [{
type: 'text',
text: 'Untitled email body.'
}]
}],
direction: null,
format: '',
indent: 0,
type: 'root',
version: 1
}
});

const buildWaitAction = (): AutomationWaitAction => ({
id: generateActionId(),
type: 'wait',
data: {wait_hours: 24}
});

const buildSendEmailAction = (): AutomationSendEmailAction => ({
id: generateActionId(),
type: 'send_email',
data: {
email_subject: 'Untitled email',
email_lexical: PLACEHOLDER_EMAIL_LEXICAL,
email_sender_name: null,
email_sender_email: null,
email_sender_reply_to: null,
// TODO NY-1252: replace this placeholder when email design settings are available.
email_design_setting_id: 'placeholder'
}
});

// Anchor for where to splice a new action into the chain. `undefined` on either side means "no
// real action there" — i.e. inserting at the head (no previousActionId) or the tail (no
// nextActionId). When both are defined, we're inserting between two existing actions and the
// direct edge between them is replaced.
export type InsertActionAnchor = {
previousActionId?: string;
nextActionId?: string;
};

type SpliceActionArgs = ReadonlyDeep<{
detail: AutomationDetail;
action: AutomationAction;
anchor: InsertActionAnchor;
}>;

type InsertActionArgs = ReadonlyDeep<{
detail: AutomationDetail;
anchor: InsertActionAnchor;
}>;

const assertActionExists = (detail: ReadonlyDeep<AutomationDetail>, id: string): void => {
if (!detail.actions.some(action => action.id === id)) {
throw new Error(`spliceAction: anchor references unknown action id "${id}"`);
}
};

const hasEdge = (detail: ReadonlyDeep<AutomationDetail>, sourceActionId: string, targetActionId: string): boolean => (
detail.edges.some(edge => edge.source_action_id === sourceActionId && edge.target_action_id === targetActionId)
);

const spliceAction = ({detail, action, anchor}: SpliceActionArgs): AutomationDetail => {
const {previousActionId, nextActionId} = anchor;
if (previousActionId !== undefined) {
assertActionExists(detail, previousActionId);
}
if (nextActionId !== undefined) {
assertActionExists(detail, nextActionId);
}
if (previousActionId === undefined && nextActionId === undefined && detail.actions.length > 0) {
throw new Error('spliceAction: anchor is required when inserting into a non-empty automation');
}
if (previousActionId !== undefined && nextActionId !== undefined && !hasEdge(detail, previousActionId, nextActionId)) {
throw new Error(`spliceAction: anchor edge "${previousActionId}" -> "${nextActionId}" does not exist`);
}
if (previousActionId !== undefined && nextActionId === undefined && detail.edges.some(edge => edge.source_action_id === previousActionId)) {
throw new Error(`spliceAction: anchor previousActionId "${previousActionId}" is not the tail action`);
}
if (previousActionId === undefined && nextActionId !== undefined && detail.edges.some(edge => edge.target_action_id === nextActionId)) {
throw new Error(`spliceAction: anchor nextActionId "${nextActionId}" is not the head action`);
}
const actions = [...detail.actions, action];
const newEdges = detail.edges.filter(edge => !(edge.source_action_id === previousActionId && edge.target_action_id === nextActionId));
if (previousActionId !== undefined) {
newEdges.push({source_action_id: previousActionId, target_action_id: action.id});
}
if (nextActionId !== undefined) {
newEdges.push({source_action_id: action.id, target_action_id: nextActionId});
}
return {...detail, actions, edges: newEdges};
};

export const insertWaitAction = ({detail, anchor}: InsertActionArgs): AutomationDetail => (
spliceAction({detail, action: buildWaitAction(), anchor})
);

export const insertSendEmailAction = ({detail, anchor}: InsertActionArgs): AutomationDetail => (
spliceAction({detail, action: buildSendEmailAction(), anchor})
);
29 changes: 29 additions & 0 deletions apps/admin-x-framework/src/api/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type Comment = {
id: string;
html: string | null;
status: 'published' | 'hidden' | 'deleted';
pinned: boolean;
created_at: string;
updated_at: string;
post_id: string;
Expand Down Expand Up @@ -138,6 +139,34 @@ export const useDeleteComment = createMutation<CommentsResponseType, {id: string
}
});

export const usePinComment = createMutation<CommentsResponseType, {id: string}>({
method: 'PUT',
path: ({id}) => `/comments/${id}/`,
body: ({id}) => ({
comments: [{
id,
pinned: true
}]
}),
invalidateQueries: {
dataType
}
});

export const useUnpinComment = createMutation<CommentsResponseType, {id: string}>({
method: 'PUT',
path: ({id}) => `/comments/${id}/`,
body: ({id}) => ({
comments: [{
id,
pinned: false
}]
}),
invalidateQueries: {
dataType
}
});

export const useCommentReplies = createQueryWithId<CommentsResponseType>({
dataType,
path: (id: string) => `/comments/${id}/replies/`,
Expand Down
194 changes: 194 additions & 0 deletions apps/admin-x-framework/test/unit/api/automations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import ObjectId from 'bson-objectid';
import {
AutomationAction,
AutomationDetail,
AutomationSendEmailAction,
InsertActionAnchor,
insertSendEmailAction,
insertWaitAction
} from '../../../src/api/automations';

const baseDetail = (actions: AutomationDetail['actions'], edges: AutomationDetail['edges']): AutomationDetail => ({
id: 'a1',
slug: 'welcome',
name: 'Welcome',
status: 'active',
created_at: '2026-05-05T00:00:00.000Z',
updated_at: '2026-05-05T00:00:00.000Z',
actions,
edges
});

function expectSendEmailAction(action: AutomationAction): asserts action is AutomationSendEmailAction {
expect(action.type).toBe('send_email');
}

const insertionCases: Array<{
name: string;
insert: (args: {detail: AutomationDetail; anchor: InsertActionAnchor}) => AutomationDetail;
expectedType: AutomationAction['type'];
}> = [
{name: 'insertWaitAction', insert: insertWaitAction, expectedType: 'wait'},
{name: 'insertSendEmailAction', insert: insertSendEmailAction, expectedType: 'send_email'}
];

describe('automations api helpers', () => {
describe('shared insertion behavior', () => {
it('appends at the tail of a non-empty chain by wiring the previous tail to the new action', () => {
const detail = baseDetail(
[{id: 'a', type: 'wait', data: {wait_hours: 24}}],
[]
);

for (const {insert, expectedType} of insertionCases) {
const next = insert({detail, anchor: {previousActionId: 'a'}});

expect(next.actions).toHaveLength(2);
const newAction = next.actions[1];
expect(newAction.type).toBe(expectedType);
expect(next.edges).toEqual([{source_action_id: 'a', target_action_id: newAction.id}]);
}
});

it('inserts at the head by leaving the new action as the new head', () => {
const detail = baseDetail(
[{id: 'a', type: 'wait', data: {wait_hours: 24}}],
[]
);

for (const {insert, expectedType} of insertionCases) {
const next = insert({detail, anchor: {nextActionId: 'a'}});

expect(next.actions).toHaveLength(2);
const newAction = next.actions[1];
expect(newAction.type).toBe(expectedType);
expect(next.edges).toEqual([{source_action_id: newAction.id, target_action_id: 'a'}]);
}
});

it('inserts between two existing actions by replacing one edge with two', () => {
const detail = baseDetail(
[
{id: 'a', type: 'wait', data: {wait_hours: 24}},
{id: 'b', type: 'wait', data: {wait_hours: 48}}
],
[{source_action_id: 'a', target_action_id: 'b'}]
);

for (const {insert, expectedType} of insertionCases) {
const next = insert({detail, anchor: {previousActionId: 'a', nextActionId: 'b'}});

expect(next.actions).toHaveLength(3);
const newAction = next.actions[2];
expect(newAction.type).toBe(expectedType);
expect(next.edges).toContainEqual({source_action_id: 'a', target_action_id: newAction.id});
expect(next.edges).toContainEqual({source_action_id: newAction.id, target_action_id: 'b'});
expect(next.edges).not.toContainEqual({source_action_id: 'a', target_action_id: 'b'});
expect(next.edges).toHaveLength(2);
}
});
});

describe('insertWaitAction', () => {
it('creates a wait action with placeholder defaults', () => {
const detail = baseDetail([], []);

const next = insertWaitAction({detail, anchor: {}});

expect(next.actions).toHaveLength(1);
expect(next.actions[0]).toMatchObject({type: 'wait', data: {wait_hours: 24}});
expect(next.edges).toEqual([]);
});

it('uses ObjectId-compatible action ids', () => {
const next = insertWaitAction({detail: baseDetail([], []), anchor: {}});

expect(ObjectId.isValid(next.actions[0].id)).toBe(true);
});

it('throws when previousActionId references a non-existent action', () => {
const detail = baseDetail(
[{id: 'a', type: 'wait', data: {wait_hours: 24}}],
[]
);

expect(() => insertWaitAction({detail, anchor: {previousActionId: 'does-not-exist'}})).toThrow(/unknown action id "does-not-exist"/);
});

it('throws when nextActionId references a non-existent action', () => {
const detail = baseDetail(
[{id: 'a', type: 'wait', data: {wait_hours: 24}}],
[]
);

expect(() => insertWaitAction({detail, anchor: {nextActionId: 'does-not-exist'}})).toThrow(/unknown action id "does-not-exist"/);
});

it('throws when inserting without an anchor into a non-empty automation', () => {
const detail = baseDetail(
[{id: 'a', type: 'wait', data: {wait_hours: 24}}],
[]
);

expect(() => insertWaitAction({detail, anchor: {}})).toThrow(/anchor is required/);
});

it('throws when inserting between actions that are not directly connected', () => {
const detail = baseDetail(
[
{id: 'a', type: 'wait', data: {wait_hours: 24}},
{id: 'b', type: 'wait', data: {wait_hours: 48}}
],
[]
);

expect(() => insertWaitAction({detail, anchor: {previousActionId: 'a', nextActionId: 'b'}})).toThrow(/anchor edge "a" -> "b" does not exist/);
});

it('throws when appending after an action that already has an outgoing edge', () => {
const detail = baseDetail(
[
{id: 'a', type: 'wait', data: {wait_hours: 24}},
{id: 'b', type: 'wait', data: {wait_hours: 48}}
],
[{source_action_id: 'a', target_action_id: 'b'}]
);

expect(() => insertWaitAction({detail, anchor: {previousActionId: 'a'}})).toThrow(/previousActionId "a" is not the tail action/);
});

it('throws when prepending before an action that already has an incoming edge', () => {
const detail = baseDetail(
[
{id: 'a', type: 'wait', data: {wait_hours: 24}},
{id: 'b', type: 'wait', data: {wait_hours: 48}}
],
[{source_action_id: 'a', target_action_id: 'b'}]
);

expect(() => insertWaitAction({detail, anchor: {nextActionId: 'b'}})).toThrow(/nextActionId "b" is not the head action/);
});
});

describe('insertSendEmailAction', () => {
it('creates a send_email action with placeholder defaults', () => {
const detail = baseDetail([], []);

const next = insertSendEmailAction({detail, anchor: {}});

expect(next.actions).toHaveLength(1);
const newAction = next.actions[0];
expectSendEmailAction(newAction);
expect(newAction.data.email_subject).toBe('Untitled email');
expect(() => JSON.parse(newAction.data.email_lexical)).not.toThrow();
expect(JSON.parse(newAction.data.email_lexical).root.children.length).toBeGreaterThan(0);
expect(newAction.data.email_design_setting_id).toBe('placeholder');
});

it('returns an action id that the backend schema treats as a valid ObjectId', () => {
const next = insertSendEmailAction({detail: baseDetail([], []), anchor: {}});

expect(ObjectId.isValid(next.actions[0].id)).toBe(true);
});
});
});
Loading
Loading