Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1dcb633
feat(workflows): list emails sent from code with transactional emails
Silthus Nov 9, 2026
46f9003
Merge branch 'feat/transactional-code-emails-api' into feat/transacti…
Silthus Nov 9, 2026
dbf4805
fix(workflows): harden the transactional emails list
Silthus Nov 9, 2026
76a1706
fix(workflows): save each row's tag edits in order
Silthus Nov 9, 2026
0573e58
fix(workflows): fence the list's last-resort tag recovery
Silthus Nov 9, 2026
c8043c2
chore(workflows): type the access level the table test sets
Silthus Nov 9, 2026
4a5f2c9
fix(workflows): restore the confirmed tags when a burst of edits fails
Silthus Nov 9, 2026
dd067fe
fix(workflows): keep a stale tag read-back out of the confirmed tags
Silthus Nov 9, 2026
4a57dd4
chore(workflows): split the transactional tag save into steps
Silthus Nov 9, 2026
ec2e1df
Merge branch 'feat/transactional-code-emails-api' into feat/transacti…
Silthus Nov 9, 2026
0705bfa
fix(workflows): keep newer template tags when an older save answers
Silthus Nov 9, 2026
c3d1eb4
fix(workflows): wrap a long code email key in the transactional list
Silthus Nov 9, 2026
5a3df0b
chore(workflows): test a failed first template tag save with edits wa…
Silthus Nov 9, 2026
91005d6
Merge branch 'feat/transactional-code-emails-api' into feat/transacti…
Silthus Nov 9, 2026
5421e75
Merge branch 'feat/transactional-code-emails-api' into feat/transacti…
Silthus Nov 10, 2026
72d0ddb
Merge branch 'feat/transactional-code-emails-api' into feat/transacti…
Silthus Nov 11, 2026
307b1a7
Merge branch 'master' into feat/transactional-code-emails-list
Silthus Nov 11, 2026
dd7e1d2
chore: rerun backend ci
Silthus Nov 11, 2026
6344c28
Merge branch 'feat/transactional-code-emails-api' into feat/transacti…
Silthus Nov 11, 2026
b38e2a0
Merge branch 'feat/transactional-code-emails-api' into feat/transacti…
Silthus Nov 11, 2026
a10cbf3
Merge branch 'feat/transactional-code-emails-api' into feat/transacti…
Silthus Nov 12, 2026
fee73c9
Merge branch 'feat/transactional-code-emails-api' into feat/transacti…
Silthus Nov 12, 2026
d61166b
chore: rerun backend ci
Silthus Nov 12, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,14 @@ export const messageTemplatesLogic = kea<messageTemplatesLogicType>([

// A plain function rather than a listener, because the caller awaits it to keep its editor open on
// a rejection, and a kea listener that rethrows raises an unhandled rejection on the way out.
export async function saveTemplateTags(template: MessageTemplate, tags: string[]): Promise<void> {
// A caller that already shows newer tags passes isLatestEdit, so this save's answer doesn't replace them.
export async function saveTemplateTags(
template: MessageTemplate,
tags: string[],
isLatestEdit: () => boolean = () => true
): Promise<void> {
const updated = await api.messaging.updateTemplate(template.id, { tags })
messageTemplatesLogic.findMounted()?.actions.templateTagsSaved(updated)
if (isLatestEdit()) {
messageTemplatesLogic.findMounted()?.actions.templateTagsSaved(updated)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Meta, StoryFn } from '@storybook/react'

import { useStorybookMocks } from '~/mocks/browser'

import type { CodeEmailApi } from 'products/messaging/frontend/generated/api.schemas'

import { TransactionalEmailsTable } from './TransactionalEmailsTable'

const templates = {
count: 2,
next: null,
previous: null,
results: [
{
id: '0190a1b2-0000-7000-8000-000000000001',
name: 'Welcome',
description: 'Sent after someone signs up',
tags: ['onboarding'],
sensitive: false,
transactional_hog_flow_id: '0190a1b2-0000-7000-8000-0000000000f1',
created_by: { id: 1, uuid: 'user-1', first_name: 'Ava', email: 'ava@example.com' },
created_at: '2026-10-01T10:00:00Z',
updated_at: '2026-11-02T10:00:00Z',
deleted: false,
},
{
id: '0190a1b2-0000-7000-8000-000000000002',
name: 'Sign-in code',
description: '',
tags: ['auth'],
sensitive: true,
transactional_hog_flow_id: '0190a1b2-0000-7000-8000-0000000000f2',
created_by: { id: 2, uuid: 'user-2', first_name: 'Ben', email: 'ben@example.com' },
created_at: '2026-09-01T10:00:00Z',
updated_at: '2026-10-20T10:00:00Z',
deleted: false,
},
],
}

const codeEmail = (id: string, key: string, fields: Partial<CodeEmailApi>): CodeEmailApi => ({
id,
key,
hog_flow_id: `0190a1b2-0000-7000-8000-0000000000c${id}`,
description: '',
tags: [],
sensitive: true,
source_url: null,
created_at: '2026-11-01T10:00:00Z',
user_access_level: 'editor',
...fields,
})

const codeEmails: CodeEmailApi[] = [
codeEmail('1', 'password-reset', {
description: 'Sent when someone asks to reset their password',
tags: ['auth'],
source_url: 'https://github.com/acme/web/blob/main/src/emails/password-reset.tsx',
}),
codeEmail('2', 'invoice-paid', {
tags: ['billing'],
source_url: 'https://gitlab.com/acme/platform/billing/-/blob/main/app/mailers/invoice_paid.rb',
}),
codeEmail('3', 'order-shipped', {}),
]

const meta: Meta = {
title: 'Products/Workflows/TransactionalEmailsTable',
component: TransactionalEmailsTable,
parameters: {
layout: 'padded',
viewMode: 'story',
testOptions: { waitForSelector: '[data-attr="transactional-emails-table"]', snapshotBrowsers: [] },
},
}
export default meta

export const TemplatesAndEmailsSentFromCode: StoryFn = () => {
useStorybookMocks({
get: {
'/api/projects/:team_id/messaging_templates/': templates,
'/api/projects/:team_id/transactional_code_emails/': codeEmails,
'/api/projects/:team_id/tags': ['auth', 'billing', 'onboarding'],
},
})
return <TransactionalEmailsTable />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import '@testing-library/jest-dom'

import { cleanup, render, waitFor } from '@testing-library/react'
import { Provider } from 'kea'

import { useMocks } from '~/mocks/jest'
import { initKeaTests } from '~/test/init'
import { AccessControlLevel, AccessControlResourceType } from '~/types'

import type { CodeEmailApi } from 'products/messaging/frontend/generated/api.schemas'

import { TransactionalEmailsTable } from './TransactionalEmailsTable'

const codeEmail = (key: string, userAccessLevel: AccessControlLevel): CodeEmailApi => ({
id: `id-${key}`,
key,
hog_flow_id: `flow-${key}`,
description: '',
tags: ['auth'],
sensitive: true,
source_url: null,
created_at: '2026-01-01T00:00:00Z',
user_access_level: userAccessLevel,
})

describe('TransactionalEmailsTable', () => {
afterEach(cleanup)

it.each([
['a workflow editor who can only view this email', AccessControlLevel.Editor, AccessControlLevel.Viewer, 0],
['a workflow viewer who can edit this email', AccessControlLevel.Viewer, AccessControlLevel.Editor, 1],
])(
'lets %s edit its tags only with editor access to the email',
async (_who, projectLevel, emailLevel, editButtons) => {
useMocks({
get: {
'/api/projects/:team_id/messaging_templates/': { results: [] },
'/api/projects/:team_id/transactional_code_emails/': [codeEmail('password-reset', emailLevel)],
'/api/projects/:team_id/tags': [],
},
})
initKeaTests()
const context = window.POSTHOG_APP_CONTEXT!
window.POSTHOG_APP_CONTEXT = {
...context,
resource_access_control: {
...context.resource_access_control,
[AccessControlResourceType.Workflow]: projectLevel,
},
}
const { container } = render(
<Provider>
<TransactionalEmailsTable />
</Provider>
)

await waitFor(() =>
expect(container.querySelector('[data-attr=transactional-emails-table]')).toHaveTextContent(
'password-reset'
)
)

const row = container.querySelector('[data-attr=transactional-emails-table] tbody tr')!
expect(row.querySelectorAll('[data-attr=button-add-tag]')).toHaveLength(editButtons)
}
)
})
Loading
Loading