diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplatesLogic.ts b/products/workflows/frontend/TemplateLibrary/messageTemplatesLogic.ts index 87df1878e709..cc7cba0d8177 100644 --- a/products/workflows/frontend/TemplateLibrary/messageTemplatesLogic.ts +++ b/products/workflows/frontend/TemplateLibrary/messageTemplatesLogic.ts @@ -274,7 +274,14 @@ export const messageTemplatesLogic = kea([ // 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 { +// 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 { const updated = await api.messaging.updateTemplate(template.id, { tags }) - messageTemplatesLogic.findMounted()?.actions.templateTagsSaved(updated) + if (isLatestEdit()) { + messageTemplatesLogic.findMounted()?.actions.templateTagsSaved(updated) + } } diff --git a/products/workflows/frontend/Transactional/TransactionalEmailsTable.stories.tsx b/products/workflows/frontend/Transactional/TransactionalEmailsTable.stories.tsx new file mode 100644 index 000000000000..ecf375902dc9 --- /dev/null +++ b/products/workflows/frontend/Transactional/TransactionalEmailsTable.stories.tsx @@ -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 => ({ + 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 +} diff --git a/products/workflows/frontend/Transactional/TransactionalEmailsTable.test.tsx b/products/workflows/frontend/Transactional/TransactionalEmailsTable.test.tsx new file mode 100644 index 000000000000..419a9a5d36e7 --- /dev/null +++ b/products/workflows/frontend/Transactional/TransactionalEmailsTable.test.tsx @@ -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( + + + + ) + + 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) + } + ) +}) diff --git a/products/workflows/frontend/Transactional/TransactionalEmailsTable.tsx b/products/workflows/frontend/Transactional/TransactionalEmailsTable.tsx index cc6e4d247cc5..ef18cce110bf 100644 --- a/products/workflows/frontend/Transactional/TransactionalEmailsTable.tsx +++ b/products/workflows/frontend/Transactional/TransactionalEmailsTable.tsx @@ -1,8 +1,9 @@ import { useActions, useValues } from 'kea' import { router } from 'kea-router' +import posthog from 'posthog-js' -import { IconLock } from '@posthog/icons' -import { LemonInput, LemonTag, Link } from '@posthog/lemon-ui' +import { IconCode, IconLetter, IconLock } from '@posthog/icons' +import { LemonInput, LemonSelect, LemonTag, Link } from '@posthog/lemon-ui' import { AppMetricsSparkline } from 'lib/components/AppMetrics/AppMetricsSparkline' import { MemberSelect } from 'lib/components/MemberSelect' @@ -10,9 +11,11 @@ import { ObjectTags } from 'lib/components/ObjectTags/ObjectTags' import { ProductIntroduction } from 'lib/components/ProductIntroduction/ProductIntroduction' import { TagSelect } from 'lib/components/TagSelect' import { dayjs } from 'lib/dayjs' +import { useResizeBreakpoints } from 'lib/hooks/useResizeObserver' import { LemonTable, LemonTableColumn } from 'lib/lemon-ui/LemonTable' import { createdByColumn, updatedAtColumn } from 'lib/lemon-ui/LemonTable/columnUtils' import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink' +import { ProfilePicture } from 'lib/lemon-ui/ProfilePicture' import { getAccessControlDisabledReason } from 'lib/utils/accessControlUtils' import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' @@ -20,27 +23,35 @@ import { urls } from 'scenes/urls' import { AccessControlLevel, AccessControlResourceType } from '~/types' import { EmailMetricsIncidentBanner } from '../EmailMetricsIncidentBanner' -import { MessageTemplate } from '../TemplateLibrary/types' +import { CodeBadge } from './code/CodeBadge' +import { ManagedBy, TransactionalEmailRow } from './transactionalEmailRows' import { transactionalEmailsLogic } from './transactionalEmailsLogic' +// Below this width the table keeps only the name and the sparkline, so it fits beside an open side panel. +const COLUMN_BREAKPOINTS = { 0: 'narrow', 760: 'wide' } as const + export function TransactionalEmailsTable(): JSX.Element { const { transactionalTemplates, - filteredTransactionalTemplates, + codeEmails, + filteredTransactionalEmails, templatesLoading, + codeEmailsLoading, filters, isFiltered, savingTagsFor, } = useValues(transactionalEmailsLogic) const { setFilters, saveTransactionalTags } = useActions(transactionalEmailsLogic) const { currentTeam } = useValues(teamLogic) + const { ref: widthRef, size } = useResizeBreakpoints(COLUMN_BREAKPOINTS, { initialSize: 'wide' }) + const wide = size === 'wide' // Same check as the "New transactional email" button: changing a template needs Editor access. const editDisabledReason = getAccessControlDisabledReason( AccessControlResourceType.Workflow, AccessControlLevel.Editor ) - if (!templatesLoading && transactionalTemplates.length === 0) { + if (!templatesLoading && transactionalTemplates.length === 0 && codeEmails?.length === 0) { return ( +
{/* The sparklines read '-7d', which starts at midnight 7 days ago in the project's timezone. */}
- Created by - setFilters({ createdBy: user?.id ?? null })} - /> - Tags - setFilters({ tags })} - listDataAttr="transactional-emails-tags-filter-list" - /> + + Managed by + + size="small" + value={filters.managedBy ?? 'all'} + onChange={(managedBy) => { + setFilters({ managedBy }) + // pinned: analytics event name + posthog.capture('transactional emails managed by filter changed', { + managed_by: managedBy, + }) + }} + options={[ + { value: 'all', label: 'All' }, + { value: 'code', label: 'Code', icon: }, + { value: 'posthog', label: 'PostHog', icon: }, + ]} + data-attr="transactional-emails-managed-by-filter" + /> + + + Created by + setFilters({ createdBy: user?.id ?? null })} + /> + + + Tags + setFilters({ tags })} + listDataAttr="transactional-emails-tags-filter-list" + /> +
- - dataSource={filteredTransactionalTemplates} - loading={templatesLoading} + + dataSource={filteredTransactionalEmails} + loading={templatesLoading || codeEmailsLoading} + pagination={{ pageSize: 50, hideOnSinglePage: true }} rowKey="id" data-attr="transactional-emails-table" columns={[ @@ -96,35 +132,44 @@ export function TransactionalEmailsTable(): JSX.Element { title: 'Name', key: 'name', sorter: (a, b) => (a.name || '').localeCompare(b.name || ''), - render: (_, template) => ( - - {template.name || 'Unnamed template'} - {template.sensitive && ( - }> - Sensitive - - )} - {template.draft_content && ( - - Unpublished changes - - )} - - } - description={template.description} - /> - ), + render: (_, row) => { + const title = ( + + {row.source === 'code' ? ( + {row.name} + ) : ( + row.name || 'Unnamed template' + )} + {row.source === 'code' && } + {row.sensitive && ( + }> + Sensitive + + )} + {row.source === 'posthog' && row.template.draft_content && ( + + Unpublished changes + + )} + + ) + return ( + + ) + }, }, { title: 'Tags', key: 'tags', width: 220, - render: function Render(_, template) { + isHidden: !wide, + render: function Render(_, row) { const shown = { - tags: [...(template.tags ?? [])], + tags: [...row.tags], onTagClick: (tag: string) => { if (!filters.tags.includes(tag)) { setFilters({ tags: [...filters.tags, tag] }) @@ -134,13 +179,24 @@ export function TransactionalEmailsTable(): JSX.Element { maxVisibleTags: 2, 'data-attr': 'transactional-email-tags', } - return editDisabledReason ? ( + // An email sent from code follows the reader's access to its own workflow. + const tagsDisabledReason = + row.source === 'code' + ? getAccessControlDisabledReason( + AccessControlResourceType.Workflow, + AccessControlLevel.Editor, + (row.codeEmail.user_access_level ?? undefined) as + | AccessControlLevel + | undefined + ) + : editDisabledReason + return tagsDisabledReason ? ( ) : ( saveTransactionalTags(template, tags)} - saving={savingTagsFor === template.id} + onChange={(tags) => saveTransactionalTags(row, tags)} + saving={savingTagsFor === row.id} /> ) }, @@ -148,37 +204,59 @@ export function TransactionalEmailsTable(): JSX.Element { { title: 'Last 7 days', width: 0, - render: (_, template) => - template.transactional_hog_flow_id ? ( - - - - ) : null, + render: (_, row) => { + if (!row.hog_flow_id) { + return null + } + const sparkline = ( + + ) + return row.source === 'posthog' ? ( + {sparkline} + ) : ( + sparkline + ) + }, + }, + { + ...(createdByColumn() as LemonTableColumn), + isHidden: !wide, + render: (_, row) => + row.source === 'code' ? ( + Your code + ) : row.created_by ? ( + + ) : ( + — + ), + }, + { + ...(updatedAtColumn() as LemonTableColumn), + width: 0, + isHidden: !wide, }, - createdByColumn() as LemonTableColumn, - { ...(updatedAtColumn() as LemonTableColumn), width: 0 }, ]} defaultSorting={{ columnKey: 'updated_at', order: -1 }} emptyState={isFiltered ? 'No transactional emails match these filters' : 'No transactional emails'} diff --git a/products/workflows/frontend/Transactional/code/CodeBadge.tsx b/products/workflows/frontend/Transactional/code/CodeBadge.tsx new file mode 100644 index 000000000000..f65f11922192 --- /dev/null +++ b/products/workflows/frontend/Transactional/code/CodeBadge.tsx @@ -0,0 +1,50 @@ +import posthog from 'posthog-js' + +import { IconCode } from '@posthog/icons' +import { LemonTag, Link, Tooltip } from '@posthog/lemon-ui' + +import { parseSourceFile } from './sourceFile' + +/** The tag that marks an email your code sends. It opens the linked source file when there is one. */ +export function CodeBadge({ sourceUrl }: { sourceUrl: string | null }): JSX.Element { + const file = sourceUrl ? parseSourceFile(sourceUrl) : null + const tag = ( + } className={file ? 'cursor-pointer' : undefined}> + Code + + ) + if (!file) { + return {tag} + } + return ( + + Managed by code in {file.path} + {file.repo ? ( + <> + {' '} + in {file.repo} + + ) : null} + . Click to open the file. + + } + > + { + // The tag sits inside the row's name link, so the click must not open the row too. + event.stopPropagation() + // pinned: analytics event name + posthog.capture('code email source file link clicked', { host: file.host }) + }} + > + {tag} + + + ) +} diff --git a/products/workflows/frontend/Transactional/code/sourceFile.test.ts b/products/workflows/frontend/Transactional/code/sourceFile.test.ts new file mode 100644 index 000000000000..93b0a1098287 --- /dev/null +++ b/products/workflows/frontend/Transactional/code/sourceFile.test.ts @@ -0,0 +1,52 @@ +import { displayRef, parseSourceFile } from './sourceFile' + +describe('sourceFile', () => { + it.each([ + [ + 'a GitHub file', + 'https://github.com/acme/web/blob/main/src/emails/password-reset.tsx', + { host: 'github', repo: 'acme/web', path: 'src/emails/password-reset.tsx', ref: 'main' }, + ], + [ + 'a GitHub file with a line anchor', + 'https://github.com/acme/web/blob/main/src/emails/receipt.tsx?plain=1#L12', + { host: 'github', repo: 'acme/web', path: 'src/emails/receipt.tsx', ref: 'main' }, + ], + [ + 'a GitLab file in a nested group', + 'https://gitlab.com/acme/shop/fulfilment/-/blob/4f2c9e1a7b/app/mailers/order_shipped.rb', + { host: 'gitlab', repo: 'acme/shop/fulfilment', path: 'app/mailers/order_shipped.rb', ref: '4f2c9e1a7b' }, + ], + [ + 'a GitHub file whose name has a stray percent sign', + 'https://github.com/acme/web/blob/main/src/emails/100%.tsx', + { host: 'github', repo: 'acme/web', path: 'src/emails/100%.tsx', ref: 'main' }, + ], + [ + 'a GitLab file with a broken escape', + 'https://gitlab.com/acme/web/-/blob/release%ZZ/src/a%ZZ.ts', + { host: 'gitlab', repo: 'acme/web', path: 'src/a%ZZ.ts', ref: 'release%ZZ' }, + ], + [ + 'a file on another host', + 'https://git.example.com/billing/emails/invoice_paid.py', + { host: 'other', repo: '', path: 'git.example.com/billing/emails/invoice_paid.py', ref: '' }, + ], + [ + 'a GitHub link that is not a file', + 'https://github.com/acme/web', + { host: 'other', repo: '', path: 'github.com/acme/web', ref: '' }, + ], + ])('reads %s', (_name, url, expected) => { + expect(parseSourceFile(url)).toEqual({ url, ...expected }) + }) + + it.each([ + ['main', 'main'], + ['4f2c9e1a7b3d5f6e8a9b0c1d2e3f4a5b6c7d8e9f', '4f2c9e1'], + ['4f2c9e1a7b', '4f2c9e1'], + ['release-2026', 'release-2026'], + ])('shows the ref %s as %s', (ref, shown) => { + expect(displayRef(ref)).toEqual(shown) + }) +}) diff --git a/products/workflows/frontend/Transactional/code/sourceFile.ts b/products/workflows/frontend/Transactional/code/sourceFile.ts new file mode 100644 index 000000000000..003de16e5d7f --- /dev/null +++ b/products/workflows/frontend/Transactional/code/sourceFile.ts @@ -0,0 +1,63 @@ +export type SourceFileHost = 'github' | 'gitlab' | 'other' + +export interface SourceFile { + url: string + host: SourceFileHost + /** Empty on another host, where the path carries the whole link. */ + repo: string + path: string + ref: string +} + +const GITHUB_FILE = /^\/([^/]+\/[^/]+)\/blob\/([^/]+)\/(.+)$/ +// GitLab groups nest, so the repository is everything before the `/-/blob/` marker. +const GITLAB_FILE = /^\/(.+?)\/-\/blob\/([^/]+)\/(.+)$/ + +/** Reads the repository, path and ref out of a GitHub or GitLab file link. */ +export function parseSourceFile(url: string): SourceFile { + const parsed = safeUrl(url) + const host = parsed?.hostname.replace(/^www\./, '') + const match = + host === 'github.com' + ? parsed?.pathname.match(GITHUB_FILE) + : host === 'gitlab.com' + ? parsed?.pathname.match(GITLAB_FILE) + : null + if (parsed && match) { + return { + url, + host: host === 'github.com' ? 'github' : 'gitlab', + repo: match[1], + ref: safeDecode(match[2]), + path: safeDecode(match[3]), + } + } + const path = parsed ? `${parsed.hostname}${parsed.pathname}`.replace(/\/$/, '') : url + return { url, host: 'other', repo: '', path, ref: '' } +} + +export function repoUrl(file: SourceFile): string | null { + return file.host === 'other' ? null : `https://${file.host}.com/${file.repo}` +} + +/** A commit SHA is shortened the way the code hosts show it. */ +export function displayRef(ref: string): string { + return /^[0-9a-f]{7,40}$/i.test(ref) ? ref.slice(0, 7) : ref +} + +// A stray `%` in a file name is not an escape, so text that fails to decode is shown as it was typed. +function safeDecode(text: string): string { + try { + return decodeURIComponent(text) + } catch { + return text + } +} + +function safeUrl(url: string): URL | null { + try { + return new URL(url) + } catch { + return null + } +} diff --git a/products/workflows/frontend/Transactional/transactionalEmailRows.ts b/products/workflows/frontend/Transactional/transactionalEmailRows.ts new file mode 100644 index 000000000000..ff61789aa37d --- /dev/null +++ b/products/workflows/frontend/Transactional/transactionalEmailRows.ts @@ -0,0 +1,78 @@ +import type { UserBasicType } from '~/types' + +import type { CodeEmailApi } from 'products/messaging/frontend/generated/api.schemas' + +import type { MessageTemplate } from '../TemplateLibrary/types' + +export type ManagedBy = 'all' | 'code' | 'posthog' + +export interface TransactionalEmailsFilters { + search: string + createdBy: number | null + tags: string[] + managedBy: ManagedBy +} + +export const DEFAULT_FILTERS: TransactionalEmailsFilters = { search: '', createdBy: null, tags: [], managedBy: 'all' } + +interface RowFields { + id: string + name: string + description: string + tags: string[] + sensitive: boolean + hog_flow_id: string | null + created_by: UserBasicType | null + updated_at: string | null +} + +/** One row of the Transactional emails list: a template designed in PostHog, or an email your code sends. */ +export type TransactionalEmailRow = RowFields & + ({ source: 'posthog'; template: MessageTemplate } | { source: 'code'; codeEmail: CodeEmailApi }) + +export function templateRow(template: MessageTemplate): TransactionalEmailRow { + return { + source: 'posthog', + template, + id: template.id, + name: template.name, + description: template.description, + tags: template.tags ?? [], + sensitive: !!template.sensitive, + hog_flow_id: template.transactional_hog_flow_id ?? null, + created_by: template.created_by, + updated_at: template.updated_at, + } +} + +export function codeEmailRow(codeEmail: CodeEmailApi): TransactionalEmailRow { + return { + source: 'code', + codeEmail, + id: codeEmail.id, + name: codeEmail.key, + description: codeEmail.description, + tags: codeEmail.tags, + sensitive: codeEmail.sensitive, + hog_flow_id: codeEmail.hog_flow_id, + created_by: null, + updated_at: null, + } +} + +export function filterTransactionalEmails( + rows: TransactionalEmailRow[], + filters: Partial +): TransactionalEmailRow[] { + const { search, createdBy, tags, managedBy } = { ...DEFAULT_FILTERS, ...filters } + const lowerSearch = search.toLowerCase() + return rows.filter( + (row) => + (managedBy === 'all' || row.source === managedBy) && + // Your code creates a code email, so filtering by a person leaves only templates. + (createdBy == null || row.created_by?.id === createdBy) && + (!tags.length || row.tags.some((tag) => tags.includes(tag))) && + (!lowerSearch || + [row.name, row.description, ...row.tags].some((text) => text.toLowerCase().includes(lowerSearch))) + ) +} diff --git a/products/workflows/frontend/Transactional/transactionalEmailsLogic.test.ts b/products/workflows/frontend/Transactional/transactionalEmailsLogic.test.ts index b82dbaab04d8..3da0e36cf186 100644 --- a/products/workflows/frontend/Transactional/transactionalEmailsLogic.test.ts +++ b/products/workflows/frontend/Transactional/transactionalEmailsLogic.test.ts @@ -1,10 +1,15 @@ +import { MOCK_DEFAULT_PROJECT, MOCK_DEFAULT_TEAM } from 'lib/api.mock' + import { router } from 'kea-router' import { expectLogic } from 'kea-test-utils' import { useMocks } from '~/mocks/jest' import { initKeaTests } from '~/test/init' +import type { CodeEmailApi } from 'products/messaging/frontend/generated/api.schemas' + import type { MessageTemplate } from '../TemplateLibrary/types' +import type { TransactionalEmailRow } from './transactionalEmailRows' import { transactionalEmailsLogic } from './transactionalEmailsLogic' const template = (id: string, name: string, tags: string[], createdBy: number, transactional = true): MessageTemplate => @@ -17,9 +22,28 @@ const template = (id: string, name: string, tags: string[], createdBy: number, t transactional_hog_flow_id: transactional ? `flow-${id}` : null, }) as unknown as MessageTemplate +const codeEmail = (id: string, key: string, tags: string[]): CodeEmailApi => ({ + id, + key, + hog_flow_id: `flow-${id}`, + description: '', + tags, + sensitive: true, + source_url: null, + created_at: '2026-01-01T00:00:00Z', + user_access_level: 'editor', +}) + +// The code email endpoints are scoped by team, so the mocks below answer only under the team's id. +const TEAM_ID = 42 +const PROJECT_ID = 7 +const CODE_EMAILS = `/api/projects/${TEAM_ID}/transactional_code_emails/` + describe('transactionalEmailsLogic', () => { let logic: ReturnType - let writes: { id: string; tags: string[] }[] + const codeRow = (): TransactionalEmailRow => + logic.values.transactionalEmails.find((r) => r.name === 'password-changed')! + let writes: { path: string; tags: string[] }[] beforeEach(async () => { writes = [] @@ -28,9 +52,15 @@ describe('transactionalEmailsLogic', () => { '/api/projects/:team_id/messaging_templates/:id/': async ({ request, params }) => { const { tags } = (await request.json()) as { tags: string[] } const id = String(params.id) - writes.push({ id, tags }) + writes.push({ path: `messaging_templates/${id}`, tags }) return [200, { ...template(id, 'Password reset', [], 1), tags }] }, + [`${CODE_EMAILS}:id/`]: async ({ request, params }) => { + const { tags } = (await request.json()) as { tags: string[] } + const id = String(params.id) + writes.push({ path: `transactional_code_emails/${id}`, tags }) + return [200, codeEmail(id, 'password-changed', tags)] + }, }, get: { '/api/projects/:team_id/messaging_templates/': { @@ -41,9 +71,14 @@ describe('transactionalEmailsLogic', () => { template('d', 'Newsletter', ['auth'], 1, false), ], }, + [CODE_EMAILS]: [codeEmail('k', 'password-changed', ['auth'])], }, }) - initKeaTests() + initKeaTests( + true, + { ...MOCK_DEFAULT_TEAM, id: TEAM_ID, project_id: PROJECT_ID }, + { ...MOCK_DEFAULT_PROJECT, id: PROJECT_ID } + ) logic = transactionalEmailsLogic() logic.mount() await expectLogic(logic).toFinishAllListeners() @@ -52,17 +87,29 @@ describe('transactionalEmailsLogic', () => { afterEach(() => logic.unmount()) it.each([ - ['a tag', `/transactional?tags=${encodeURIComponent('["auth"]')}`, ['Password reset', 'Sign-in code']], + [ + 'a tag', + `/transactional?tags=${encodeURIComponent('["auth"]')}`, + ['Password reset', 'Sign-in code', 'password-changed'], + ], ['the creator', '/transactional?created_by=2', ['Receipt', 'Sign-in code']], - ['a search', '/transactional?search=receipt', ['Receipt']], + ['a search', '/transactional?search=password', ['Password reset', 'password-changed']], + ['code', '/transactional?managed_by=code', ['password-changed']], + ['PostHog', '/transactional?managed_by=posthog', ['Password reset', 'Receipt', 'Sign-in code']], ])('filters transactional emails by %s from the url', async (_name, url, expected) => { router.actions.push(url) await expectLogic(logic).toMatchValues({ - filteredTransactionalTemplates: expected.map((name) => expect.objectContaining({ name })), + filteredTransactionalEmails: expected.map((name) => expect.objectContaining({ name })), }) }) + it('writes the managed by filter into the url', () => { + logic.actions.setFilters({ managedBy: 'code' }) + + expect(router.values.searchParams).toMatchObject({ managed_by: 'code' }) + }) + it('keeps the filters when a link back to the list carries no query params', () => { router.actions.push('/transactional?search=reset&created_by=1') @@ -72,20 +119,28 @@ describe('transactionalEmailsLogic', () => { expect(router.values.searchParams).toMatchObject({ search: 'reset' }) }) - it('saves two quick tag edits as one write that keeps both tags', async () => { - const reset = logic.values.filteredTransactionalTemplates.find((t) => t.name === 'Password reset')! + it.each([ + ['a template', 'Password reset', 'messaging_templates/a'], + ['an email sent from code', 'password-changed', 'transactional_code_emails/k'], + ])('saves two quick tag edits on %s as one write that keeps both tags', async (_source, name, path) => { + const row = logic.values.transactionalEmails.find((r) => r.name === name)! - logic.actions.saveTransactionalTags(reset, ['auth', 'security']) - const shown = logic.values.filteredTransactionalTemplates.find((t) => t.name === 'Password reset')! - logic.actions.saveTransactionalTags(shown, [...shown.tags!, 'urgent']) + logic.actions.saveTransactionalTags(row, ['auth', 'security']) + const shown = logic.values.transactionalEmails.find((r) => r.name === name)! + logic.actions.saveTransactionalTags(shown, [...shown.tags, 'urgent']) await expectLogic(logic).toFinishAllListeners() - expect(writes).toEqual([{ id: 'a', tags: ['auth', 'security', 'urgent'] }]) + expect(writes).toEqual([{ path, tags: ['auth', 'security', 'urgent'] }]) + expect(logic.values.transactionalEmails.find((r) => r.name === name)!.tags).toEqual([ + 'auth', + 'security', + 'urgent', + ]) }) it('saves tag edits made on two rows within the same pause', async () => { const [reset, receipt] = ['Password reset', 'Receipt'].map( - (name) => logic.values.transactionalTemplates.find((t) => t.name === name)! + (name) => logic.values.transactionalEmails.find((r) => r.name === name)! ) logic.actions.saveTransactionalTags(reset, ['auth', 'security']) @@ -93,8 +148,166 @@ describe('transactionalEmailsLogic', () => { await expectLogic(logic).toFinishAllListeners() expect(writes).toEqual([ - { id: 'a', tags: ['auth', 'security'] }, - { id: 'b', tags: ['billing', 'urgent'] }, + { path: 'messaging_templates/a', tags: ['auth', 'security'] }, + { path: 'messaging_templates/b', tags: ['billing', 'urgent'] }, ]) }) + + it.each([404, 500])('keeps the template rows when the code emails answer %s', async (status) => { + logic.unmount() + useMocks({ get: { [CODE_EMAILS]: () => [status, { detail: 'Unavailable' }] } }) + logic = transactionalEmailsLogic() + logic.mount() + await expectLogic(logic).toFinishAllListeners() + + expect(logic.values.codeEmailsLoading).toBe(false) + expect(logic.values.transactionalEmails.map((row) => row.name)).toEqual([ + 'Password reset', + 'Receipt', + 'Sign-in code', + ]) + }) + + it.each([ + ['a template', 'answers', 'Password reset'], + ['a template', 'fails', 'Password reset'], + ['an email sent from code', 'answers', 'password-changed'], + ['an email sent from code', 'fails', 'password-changed'], + ])('stores every tag when edits on %s are made while the first save %s', async (_source, first, name) => { + jest.useFakeTimers() + try { + let stored = ['auth'] + const held: (() => void)[] = [] + const save = async (request: Request, path: string): Promise => { + const { tags } = (await request.json()) as { tags: string[] } + const write = writes.push({ path, tags }) + if (write <= 2) { + await new Promise((resolve) => held.push(resolve)) + } + if (write === 1 && first === 'fails') { + return null + } + stored = tags + return tags + } + useMocks({ + patch: { + '/api/projects/:team_id/messaging_templates/:id/': async ({ request, params }) => { + const tags = await save(request, `messaging_templates/${String(params.id)}`) + return tags + ? [200, { ...template(String(params.id), 'Password reset', [], 1), tags }] + : [500, { detail: 'Unavailable' }] + }, + [`${CODE_EMAILS}:id/`]: async ({ request, params }) => { + const tags = await save(request, `transactional_code_emails/${String(params.id)}`) + return tags + ? [200, codeEmail(String(params.id), 'password-changed', tags)] + : [500, { detail: 'Unavailable' }] + }, + }, + get: { + '/api/projects/:team_id/messaging_templates/': () => [ + 200, + { results: [template('a', 'Password reset', stored, 1)] }, + ], + [CODE_EMAILS]: () => [200, [codeEmail('k', 'password-changed', stored)]], + }, + }) + const row = (): TransactionalEmailRow => logic.values.transactionalEmails.find((r) => r.name === name)! + + logic.actions.saveTransactionalTags(row(), ['auth', 'security']) + await jest.advanceTimersByTimeAsync(300) + logic.actions.saveTransactionalTags(row(), [...row().tags, 'urgent']) + await jest.advanceTimersByTimeAsync(300) + expect(writes).toHaveLength(1) + held[0]() + while (held.length < 2) { + await jest.advanceTimersByTimeAsync(10) + } + await jest.advanceTimersByTimeAsync(100) + logic.actions.saveTransactionalTags(row(), [...row().tags, 'later']) + await jest.advanceTimersByTimeAsync(300) + held[1]() + await jest.advanceTimersByTimeAsync(0) + await expectLogic(logic).toFinishAllListeners() + + const all = ['auth', 'security', 'urgent', 'later'] + expect(stored).toEqual(all) + expect(row().tags).toEqual(all) + logic.actions.loadCodeEmails() + logic.actions.loadTemplates() + await expectLogic(logic).toFinishAllListeners() + expect(row().tags).toEqual(all) + } finally { + jest.useRealTimers() + } + }) + + it.each([ + [1, 200], + [1, 500], + [2, 500], + ])( + 'shows the stored tags of an email sent from code when a burst of %s tag edits fails to save and the read-back answers %s', + async (edits, status) => { + useMocks({ + patch: { [`${CODE_EMAILS}:id/`]: () => [500, { detail: 'Unavailable' }] }, + get: { [`${CODE_EMAILS}:id/`]: () => [status, codeEmail('k', 'password-changed', ['auth'])] }, + }) + + for (const tag of ['security', 'urgent'].slice(0, edits)) { + logic.actions.saveTransactionalTags(codeRow(), [...codeRow().tags, tag]) + } + await expectLogic(logic).toFinishAllListeners() + + expect(codeRow().tags).toEqual(['auth']) + expect(logic.values.savingTagsFor).toBeNull() + } + ) + + it.each([200, 500])( + 'keeps a newer tag edit when the read-back of a failed save answers %s after it, also through a later failed edit', + async (status) => { + let outage = false + let patches = 0 + let readBackStarted: () => void = () => {} + const readBack = new Promise((resolve) => (readBackStarted = resolve)) + let releaseReadBack: () => void = () => {} + const readBackHeld = new Promise((resolve) => (releaseReadBack = resolve)) + useMocks({ + patch: { + [`${CODE_EMAILS}:id/`]: async ({ request, params }) => { + const { tags } = (await request.json()) as { tags: string[] } + if (++patches === 1 || outage) { + return [500, { detail: 'Unavailable' }] + } + return [200, codeEmail(String(params.id), 'password-changed', tags)] + }, + }, + get: { + [`${CODE_EMAILS}:id/`]: async () => { + if (outage) { + return [500, { detail: 'Unavailable' }] + } + readBackStarted() + await readBackHeld + return [status, codeEmail('k', 'password-changed', ['auth'])] + }, + }, + }) + + logic.actions.saveTransactionalTags(codeRow(), ['auth', 'security']) + await readBack + logic.actions.saveTransactionalTags(codeRow(), [...codeRow().tags, 'urgent']) + await expectLogic(logic).toDispatchActions(['transactionalTagsSaved']) + releaseReadBack() + await expectLogic(logic).toFinishAllListeners() + + expect(codeRow().tags).toEqual(['auth', 'security', 'urgent']) + outage = true + logic.actions.saveTransactionalTags(codeRow(), [...codeRow().tags, 'later']) + await expectLogic(logic).toFinishAllListeners() + expect(codeRow().tags).toEqual(['auth', 'security', 'urgent']) + } + ) }) diff --git a/products/workflows/frontend/Transactional/transactionalEmailsLogic.ts b/products/workflows/frontend/Transactional/transactionalEmailsLogic.ts index 7c78b03b5484..d9a13589c713 100644 --- a/products/workflows/frontend/Transactional/transactionalEmailsLogic.ts +++ b/products/workflows/frontend/Transactional/transactionalEmailsLogic.ts @@ -1,36 +1,51 @@ -import { MakeLogicType, actions, connect, kea, listeners, path, reducers, selectors } from 'kea' +import { MakeLogicType, actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea' +import { loaders } from 'kea-loaders' import { actionToUrl, router, urlToAction } from 'kea-router' import { lemonToast } from '@posthog/lemon-ui' +import { ApiError } from 'lib/api' import { buildUserScopedPersistenceConfig } from 'lib/logic/persistence' import { objectsEqual } from 'lib/utils/objects' +import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' -import { filterMessageTemplates } from '../TemplateLibrary/filterMessageTemplates' +import { + transactionalCodeEmailsList, + transactionalCodeEmailsPartialUpdate, + transactionalCodeEmailsRetrieve, +} from 'products/messaging/frontend/generated/api' +import type { CodeEmailApi } from 'products/messaging/frontend/generated/api.schemas' + import { messageTemplatesLogic, saveTemplateTags } from '../TemplateLibrary/messageTemplatesLogic' import { MessageTemplate } from '../TemplateLibrary/types' - -export interface TransactionalEmailsFilters { - search: string - createdBy: number | null - tags: string[] -} - -const DEFAULT_FILTERS: TransactionalEmailsFilters = { search: '', createdBy: null, tags: [] } +import { + DEFAULT_FILTERS, + ManagedBy, + TransactionalEmailRow, + TransactionalEmailsFilters, + codeEmailRow, + filterTransactionalEmails, + templateRow, +} from './transactionalEmailRows' // The search params actionToUrl writes below. A URL that carries none of them states no filters at all, // which is different from stating that every filter is off. -const FILTER_URL_PARAMS = ['search', 'created_by', 'tags'] +const FILTER_URL_PARAMS = ['search', 'created_by', 'tags', 'managed_by'] +const MANAGED_BY_VALUES: ManagedBy[] = ['all', 'code', 'posthog'] // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface transactionalEmailsLogicValues { templatesLoading: boolean // messageTemplatesLogic transactionalTemplates: MessageTemplate[] // messageTemplatesLogic - filteredTransactionalTemplates: MessageTemplate[] + currentTeamId: number | null // teamLogic + codeEmails: CodeEmailApi[] | null + codeEmailsLoading: boolean + filteredTransactionalEmails: TransactionalEmailRow[] filters: TransactionalEmailsFilters isFiltered: boolean savingTagsFor: string | null + transactionalEmails: TransactionalEmailRow[] } // Generated by kea-typegen. Update if you're an agent, ignore if you're human. @@ -39,12 +54,30 @@ export interface transactionalEmailsLogicActions { templateTagsSaved: (template: MessageTemplate) => { template: MessageTemplate } // messageTemplatesLogic + codeEmailChanged: (codeEmail: CodeEmailApi) => { + codeEmail: CodeEmailApi + } + loadCodeEmails: () => any + loadCodeEmailsFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + loadCodeEmailsSuccess: ( + codeEmails: CodeEmailApi[], + payload?: any + ) => { + codeEmails: CodeEmailApi[] + payload?: any + } saveTransactionalTags: ( - template: MessageTemplate, + row: TransactionalEmailRow, tags: string[] ) => { + row: TransactionalEmailRow tags: string[] - template: MessageTemplate } setFilters: (filters: Partial) => { filters: Partial @@ -52,18 +85,22 @@ export interface transactionalEmailsLogicActions { setFiltersFromUrl: (filters: TransactionalEmailsFilters) => { filters: TransactionalEmailsFilters } - transactionalTagsSaved: (templateId: string) => { - templateId: string + transactionalTagsSaved: (rowId: string) => { + rowId: string } } // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface transactionalEmailsLogicMeta { __keaTypeGenInternalSelectorTypes: { - filteredTransactionalTemplates: ( + transactionalEmails: ( transactionalTemplates: MessageTemplate[], + codeEmails: CodeEmailApi[] | null + ) => TransactionalEmailRow[] + filteredTransactionalEmails: ( + transactionalEmails: TransactionalEmailRow[], filters: TransactionalEmailsFilters - ) => MessageTemplate[] + ) => TransactionalEmailRow[] isFiltered: (filters: TransactionalEmailsFilters) => boolean } } @@ -78,15 +115,34 @@ export type transactionalEmailsLogicType = MakeLogicType< export const transactionalEmailsLogic = kea([ path(['products', 'workflows', 'frontend', 'Transactional', 'transactionalEmailsLogic']), connect(() => ({ - values: [messageTemplatesLogic, ['transactionalTemplates', 'templatesLoading']], + values: [messageTemplatesLogic, ['transactionalTemplates', 'templatesLoading'], teamLogic, ['currentTeamId']], actions: [messageTemplatesLogic, ['loadTemplates', 'templateTagsSaved']], })), actions({ setFilters: (filters: Partial) => ({ filters }), setFiltersFromUrl: (filters: TransactionalEmailsFilters) => ({ filters }), - saveTransactionalTags: (template: MessageTemplate, tags: string[]) => ({ template, tags }), - transactionalTagsSaved: (templateId: string) => ({ templateId }), + saveTransactionalTags: (row: TransactionalEmailRow, tags: string[]) => ({ row, tags }), + transactionalTagsSaved: (rowId: string) => ({ rowId }), + codeEmailChanged: (codeEmail: CodeEmailApi) => ({ codeEmail }), }), + loaders(({ values }) => ({ + codeEmails: [ + null as CodeEmailApi[] | null, + { + loadCodeEmails: async () => { + try { + return await transactionalCodeEmailsList(String(values.currentTeamId)) + } catch (error) { + // The endpoint answers 404 while the project can't send email from code yet. + if (error instanceof ApiError && error.status === 404) { + return [] + } + throw error + } + }, + }, + ], + })), reducers(() => ({ filters: [ DEFAULT_FILTERS, @@ -102,49 +158,128 @@ export const transactionalEmailsLogic = kea([ savingTagsFor: [ null as string | null, { - saveTransactionalTags: (_, { template }) => template.id, - transactionalTagsSaved: (state, { templateId }) => (state === templateId ? null : state), + saveTransactionalTags: (_, { row }) => row.id, + transactionalTagsSaved: (state, { rowId }) => (state === rowId ? null : state), }, ], + codeEmails: { + codeEmailChanged: (state, { codeEmail }) => + state?.map((email) => (email.id === codeEmail.id ? codeEmail : email)) ?? state, + }, })), selectors({ - filteredTransactionalTemplates: [ - (s) => [s.transactionalTemplates, s.filters], - (templates: MessageTemplate[], filters: TransactionalEmailsFilters): MessageTemplate[] => - filterMessageTemplates(templates, filters), + transactionalEmails: [ + (s) => [s.transactionalTemplates, s.codeEmails], + (templates: MessageTemplate[], codeEmails: CodeEmailApi[] | null): TransactionalEmailRow[] => [ + ...templates.map(templateRow), + ...(codeEmails ?? []).map(codeEmailRow), + ], + ], + filteredTransactionalEmails: [ + (s) => [s.transactionalEmails, s.filters], + (rows: TransactionalEmailRow[], filters: TransactionalEmailsFilters): TransactionalEmailRow[] => + filterTransactionalEmails(rows, filters), ], isFiltered: [ (s) => [s.filters], - (filters: TransactionalEmailsFilters): boolean => !objectsEqual(filters, DEFAULT_FILTERS), + // Filters persisted before a filter existed lack it, so they are read over the defaults. + (filters: TransactionalEmailsFilters): boolean => + !objectsEqual({ ...DEFAULT_FILTERS, ...filters }, DEFAULT_FILTERS), ], }), - listeners(({ actions }) => ({ - saveTransactionalTags: async ({ template, tags }) => { - // The editor builds each payload from the row's tags, so a second edit made while the first - // is in flight would be built from the stored list and drop the first one. Showing the new - // tags at once gives that second edit the right starting point. - actions.templateTagsSaved({ ...template, tags }) + listeners(({ actions, values }) => { + const changeCodeEmail = (codeEmail: CodeEmailApi): void => { + transactionalEmailsLogic.findMounted()?.actions.codeEmailChanged(codeEmail) + } + + // The editor builds each payload from the row's tags, so a second edit made while the first + // is in flight would be built from the stored list and drop the first one. Showing the new + // tags at once gives that second edit the right starting point. + const showTagsAtOnce = (row: TransactionalEmailRow, tags: string[]): void => { + if (row.source !== 'code') { + actions.templateTagsSaved({ ...row.template, tags }) + return + } + if (!confirmedCodeEmails.has(row.id)) { + confirmedCodeEmails.set(row.id, row.codeEmail) + } + actions.codeEmailChanged({ ...row.codeEmail, tags }) + } + + const saveCodeEmailTags = async ( + codeEmail: CodeEmailApi, + tags: string[], + isLatestEdit: () => boolean + ): Promise => { + const updated = await transactionalCodeEmailsPartialUpdate(String(values.currentTeamId), codeEmail.id, { + tags, + }) + confirmedCodeEmails.set(codeEmail.id, updated) + if (isLatestEdit()) { + changeCodeEmail(updated) + } + } + + // The row is showing tags that were never stored, so the stored ones are read back. + const recoverCodeEmailTags = async (codeEmail: CodeEmailApi, isLatestEdit: () => boolean): Promise => { + if (!isLatestEdit()) { + return + } try { - await saveTagsAfterPause(template, tags) - } catch (error: any) { - lemonToast.error(`Couldn't save tags: ${error?.detail || error?.message || 'unknown error'}`) - // The row is showing tags that were never stored, so the stored ones are read back. - actions.loadTemplates() - } finally { - if (!pendingTagSaves.has(template.id)) { - actions.transactionalTagsSaved(template.id) + const stored = await transactionalCodeEmailsRetrieve(String(values.currentTeamId), codeEmail.id) + if (isLatestEdit()) { + confirmedCodeEmails.set(codeEmail.id, stored) + changeCodeEmail(stored) + } + } catch { + if (isLatestEdit()) { + changeCodeEmail(confirmedCodeEmails.get(codeEmail.id) ?? codeEmail) } } - }, - })), + } + + return { + saveTransactionalTags: async ({ row, tags }) => { + showTagsAtOnce(row, tags) + const isLatestEdit = startTagEdit(row.id) + try { + await saveTagsAfterPause(row.id, () => + saveInOrder(row.id, async () => { + if (!isLatestEdit()) { + return + } + return row.source === 'code' + ? saveCodeEmailTags(row.codeEmail, tags, isLatestEdit) + : saveTemplateTags(row.template, tags, isLatestEdit) + }) + ) + } catch (error: any) { + lemonToast.error(`Couldn't save tags: ${error?.detail || error?.message || 'unknown error'}`) + if (row.source === 'code') { + await recoverCodeEmailTags(row.codeEmail, isLatestEdit) + } else if (isLatestEdit()) { + actions.loadTemplates() + } + } finally { + if (isLatestEdit()) { + confirmedCodeEmails.delete(row.id) + } + if (!pendingTagSaves.has(row.id)) { + actions.transactionalTagsSaved(row.id) + } + } + }, + } + }), actionToUrl(({ values }) => ({ setFilters: () => { - const { search, createdBy, tags } = values.filters + const { search, createdBy, tags, managedBy } = { ...DEFAULT_FILTERS, ...values.filters } const searchParams = { ...router.values.searchParams, search: search || undefined, created_by: createdBy ?? undefined, tags: tags.length ? tags : undefined, + managed_by: managedBy === 'all' ? undefined : managedBy, } return [router.values.location.pathname, searchParams, router.values.hashParams, { replace: true }] }, @@ -155,13 +290,14 @@ export const transactionalEmailsLogic = kea([ // params. Parsing that URL would clear every filter, so the filters held in state are restored // and written back into the URL instead. if (!FILTER_URL_PARAMS.some((param) => param in searchParams)) { - if (!objectsEqual(values.filters, DEFAULT_FILTERS)) { + if (values.isFiltered) { actions.setFilters(values.filters) } return } const rawTags = searchParams['tags'] const createdBy = parseInt(String(searchParams['created_by'])) + const managedBy = MANAGED_BY_VALUES.find((value) => value === searchParams['managed_by']) const parsed: TransactionalEmailsFilters = { search: searchParams['search'] ? String(searchParams['search']) : '', createdBy: Number.isNaN(createdBy) ? null : createdBy, @@ -171,27 +307,53 @@ export const transactionalEmailsLogic = kea([ : typeof rawTags === 'string' && rawTags ? [rawTags] : [], + managedBy: managedBy ?? 'all', } if (!objectsEqual(parsed, values.filters)) { actions.setFiltersFromUrl(parsed) } }, })), + afterMount(({ actions }) => { + actions.loadCodeEmails() + }), ]) -// Tag writes waiting out their pause, keyed by template. They are held here rather than behind a +// The newest tag edit made on each row, so a save can tell when a later edit has replaced its tags. +const latestTagEdits = new Map() + +function startTagEdit(rowId: string): () => boolean { + const edit = (latestTagEdits.get(rowId) ?? 0) + 1 + latestTagEdits.set(rowId, edit) + return () => latestTagEdits.get(rowId) === edit +} + +// The last tags the server confirmed for each email sent from code with edits in progress. A row already shows +// unsaved tags while a burst of edits waits, so it can't tell what to go back to when every save and read fails. +const confirmedCodeEmails = new Map() + +// Writes on one row run one after another, so the server stores tags in the order the user made them. +const tagWrites = new Map>() + +function saveInOrder(rowId: string, save: () => Promise): Promise { + const write = (tagWrites.get(rowId) ?? Promise.resolve()).catch(() => undefined).then(save) + tagWrites.set(rowId, write) + return write +} + +// Tag writes waiting out their pause, keyed by row. They are held here rather than behind a // listener breakpoint, which would also cancel them when another row is edited or the list unmounts. const pendingTagSaves = new Map void>() // One write per burst of clicks on a row, so two quick edits become a single save that keeps both tags. -function saveTagsAfterPause(template: MessageTemplate, tags: string[]): Promise { - pendingTagSaves.get(template.id)?.() +function saveTagsAfterPause(rowId: string, save: () => Promise): Promise { + pendingTagSaves.get(rowId)?.() return new Promise((resolve, reject) => { const timer = setTimeout(() => { - pendingTagSaves.delete(template.id) - saveTemplateTags(template, tags).then(resolve, reject) + pendingTagSaves.delete(rowId) + save().then(resolve, reject) }, 300) - pendingTagSaves.set(template.id, () => { + pendingTagSaves.set(rowId, () => { clearTimeout(timer) resolve() })