@startsimpli/integrations 0.2.45 → 0.2.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/comms/__tests__/image-library.test.tsx +230 -0
- package/src/comms/api/_fetch.ts +4 -2
- package/src/comms/api/comms.ts +15 -0
- package/src/comms/components/AnnouncementComposer.tsx +29 -3
- package/src/comms/components/ImageLibrary.tsx +115 -0
- package/src/comms/hooks/index.ts +26 -0
- package/src/comms/types/index.ts +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.46",
|
|
4
4
|
"description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps, plus foundry customer email (subpath ./comms).",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image library inside the Announcements fold (bd startsim-ktuhz, picker lane).
|
|
3
|
+
*
|
|
4
|
+
* "Images" is a small section of the composer: upload a picture with its alt
|
|
5
|
+
* text (alt is required), see the foundry's library as thumbnails, insert one
|
|
6
|
+
* into the message, delete one. Uploads are multipart POSTs to comms/assets/.
|
|
7
|
+
*
|
|
8
|
+
* Inserting writes `` on its own paragraph; saving the draft turns
|
|
9
|
+
* that into an <img> (escaped, max-width 100%) in the html and `[Image: alt]` in
|
|
10
|
+
* the plain-text part. The server sanitiser has the last word on which hosts
|
|
11
|
+
* survive.
|
|
12
|
+
*
|
|
13
|
+
* TEST ENVIRONMENT NOTE: jsdom's FormData is not recognised by Node's fetch (it is
|
|
14
|
+
* sent as "[object FormData]"), so the tests wrap fetch: a FormData body is read
|
|
15
|
+
* field by field and forwarded as JSON with an X-Test-Form header. That keeps
|
|
16
|
+
* "the request carries file and alt" under test; the multipart encoding itself
|
|
17
|
+
* is the browser's. The helper test below checks the real fetch call directly.
|
|
18
|
+
*/
|
|
19
|
+
import * as React from 'react';
|
|
20
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
21
|
+
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
22
|
+
import userEvent from '@testing-library/user-event';
|
|
23
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
24
|
+
import { http, HttpResponse } from 'msw';
|
|
25
|
+
import { setupServer } from 'msw/node';
|
|
26
|
+
|
|
27
|
+
import { CustomerEmailsPage } from '../components/pages/CustomerEmailsPage';
|
|
28
|
+
import { textToHtml, textToPlain } from '../components/AnnouncementComposer';
|
|
29
|
+
import { createCommsApi, setApiBase } from '../api/index';
|
|
30
|
+
import type { CommsAsset } from '../types/index';
|
|
31
|
+
import { BASE, P, SLUG, comeback, draft, productUpdate, settings } from './fixtures';
|
|
32
|
+
|
|
33
|
+
const asset = (over: Partial<CommsAsset> = {}): CommsAsset => ({
|
|
34
|
+
id: 'img-1',
|
|
35
|
+
key: 'crochets/img-1.png',
|
|
36
|
+
url: 'https://cdn.test/crochets/img-1.png',
|
|
37
|
+
filename: 'cat.png',
|
|
38
|
+
content_type: 'image/png',
|
|
39
|
+
bytes: 1234,
|
|
40
|
+
width: 60,
|
|
41
|
+
height: 40,
|
|
42
|
+
alt: 'A cat',
|
|
43
|
+
created_at: '2026-10-31T10:00:00Z',
|
|
44
|
+
...over,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
interface Hit { method: string; path: string; body: any; form?: Record<string, string> }
|
|
48
|
+
let hits: Hit[] = [];
|
|
49
|
+
let library: CommsAsset[] = [];
|
|
50
|
+
let uploadReply: () => Response;
|
|
51
|
+
|
|
52
|
+
const rel = (p: string) => p.replace(new URL(P).pathname, '');
|
|
53
|
+
|
|
54
|
+
const server = setupServer(
|
|
55
|
+
http.all(`${P}/*`, async ({ request }) => {
|
|
56
|
+
const url = new URL(request.url);
|
|
57
|
+
const isForm = request.headers.get('X-Test-Form') === '1';
|
|
58
|
+
let body: any;
|
|
59
|
+
let form: Hit['form'];
|
|
60
|
+
if (request.method !== 'GET') {
|
|
61
|
+
const t = await request.text();
|
|
62
|
+
const parsed = t ? JSON.parse(t) : undefined;
|
|
63
|
+
if (isForm) form = parsed;
|
|
64
|
+
else body = parsed;
|
|
65
|
+
}
|
|
66
|
+
const hit: Hit = { method: request.method, path: rel(url.pathname), body, form };
|
|
67
|
+
hits.push(hit);
|
|
68
|
+
if (hit.path === '/settings/') return HttpResponse.json(settings());
|
|
69
|
+
if (hit.path === '/programs/') return HttpResponse.json([comeback(), productUpdate()]);
|
|
70
|
+
if (hit.path === '/drafts/' && hit.method === 'POST') return HttpResponse.json(draft({ id: 'a-1' }), { status: 201 });
|
|
71
|
+
if (hit.path === '/drafts/') return HttpResponse.json({ count: 0, next: null, previous: null, results: [] });
|
|
72
|
+
if (hit.path === '/assets/' && hit.method === 'POST') return uploadReply();
|
|
73
|
+
if (hit.path === '/assets/') return HttpResponse.json({ count: library.length, next: null, previous: null, results: library });
|
|
74
|
+
const m = hit.path.match(/^\/assets\/([^/]+)\/$/);
|
|
75
|
+
if (m && hit.method === 'DELETE') {
|
|
76
|
+
library = library.filter((a) => a.id !== m[1]);
|
|
77
|
+
return new HttpResponse(null, { status: 204 });
|
|
78
|
+
}
|
|
79
|
+
return HttpResponse.json({});
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
let realFetch: typeof fetch;
|
|
84
|
+
let lastInit: RequestInit | undefined;
|
|
85
|
+
|
|
86
|
+
beforeAll(() => {
|
|
87
|
+
server.listen({ onUnhandledRequest: 'error' });
|
|
88
|
+
realFetch = globalThis.fetch; // msw's patched fetch, captured after listen()
|
|
89
|
+
vi.stubGlobal('fetch', (input: RequestInfo | URL, init?: RequestInit) => {
|
|
90
|
+
if (init?.body instanceof FormData) {
|
|
91
|
+
lastInit = init; // the upload call itself (later refetches must not overwrite it)
|
|
92
|
+
const entries = Object.fromEntries(init.body.entries());
|
|
93
|
+
const headers = { ...(init.headers as Record<string, string>), 'X-Test-Form': '1', 'Content-Type': 'application/json' };
|
|
94
|
+
return realFetch.call(globalThis, input, {
|
|
95
|
+
...init,
|
|
96
|
+
headers,
|
|
97
|
+
body: JSON.stringify({ file: (entries.file as File).name, alt: String(entries.alt) }),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return realFetch.call(globalThis, input, init);
|
|
101
|
+
});
|
|
102
|
+
setApiBase(`${BASE}/api/v1`);
|
|
103
|
+
});
|
|
104
|
+
afterAll(() => {
|
|
105
|
+
server.close();
|
|
106
|
+
vi.unstubAllGlobals();
|
|
107
|
+
});
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
hits = [];
|
|
110
|
+
library = [];
|
|
111
|
+
uploadReply = () => HttpResponse.json({}, { status: 500 });
|
|
112
|
+
});
|
|
113
|
+
afterEach(() => server.resetHandlers());
|
|
114
|
+
|
|
115
|
+
async function openImages() {
|
|
116
|
+
const user = userEvent.setup();
|
|
117
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
118
|
+
render(
|
|
119
|
+
<QueryClientProvider client={qc}>
|
|
120
|
+
<CustomerEmailsPage api={createCommsApi(SLUG)} foundrySlug={SLUG} pollIntervalMs={20} />
|
|
121
|
+
</QueryClientProvider>,
|
|
122
|
+
);
|
|
123
|
+
await user.click(await screen.findByRole('button', { name: 'Announcements' }));
|
|
124
|
+
const region = await screen.findByRole('region', { name: 'Announcements' });
|
|
125
|
+
return { user, region };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const png = () => new File([new Uint8Array([137, 80, 78, 71])], 'cat.png', { type: 'image/png' });
|
|
129
|
+
const uploads = () => hits.filter((h) => h.method === 'POST' && h.path === '/assets/');
|
|
130
|
+
|
|
131
|
+
describe('textToHtml / textToPlain with images', () => {
|
|
132
|
+
it('turns a paragraph that is only an image into an escaped, responsive <img>', () => {
|
|
133
|
+
expect(textToHtml('Hello\n\n')).toBe(
|
|
134
|
+
'<p>Hello</p><p><img src="https://cdn.test/a.png" alt="A "cat"" style="max-width:100%;height:auto;border:0"></p>',
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
it('handles an image inline and escapes markup around it', () => {
|
|
138
|
+
expect(textToHtml('See  <b>now</b>')).toBe(
|
|
139
|
+
'<p>See <img src="https://cdn.test/a.png" alt="x" style="max-width:100%;height:auto;border:0"> <b>now</b></p>',
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
it('never turns a non-http(s) target into an image', () => {
|
|
143
|
+
expect(textToHtml(')')).toBe('<p>)</p>');
|
|
144
|
+
});
|
|
145
|
+
it('writes [Image: alt] in the plain-text part', () => {
|
|
146
|
+
expect(textToPlain('Hi\n\n')).toBe('Hi\n\n[Image: A cat]');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('Images section', () => {
|
|
151
|
+
it('lists the foundry library as thumbnails with their alt text', async () => {
|
|
152
|
+
library = [asset(), asset({ id: 'img-2', alt: 'A dog', url: 'https://cdn.test/crochets/img-2.png' })];
|
|
153
|
+
const { region } = await openImages();
|
|
154
|
+
const imgs = await within(region).findAllByRole('img', { name: 'A cat' });
|
|
155
|
+
expect(imgs[0]).toHaveAttribute('src', 'https://cdn.test/crochets/img-1.png');
|
|
156
|
+
expect(within(region).getByRole('img', { name: 'A dog' })).toBeInTheDocument();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('needs a file AND alt text before it will upload', async () => {
|
|
160
|
+
const { user, region } = await openImages();
|
|
161
|
+
const button = await within(region).findByRole('button', { name: 'Upload image' });
|
|
162
|
+
expect(button).toBeDisabled();
|
|
163
|
+
await user.upload(within(region).getByLabelText('Image file'), png());
|
|
164
|
+
expect(button).toBeDisabled();
|
|
165
|
+
await user.type(within(region).getByLabelText('Describe the image'), 'A cat');
|
|
166
|
+
expect(button).toBeEnabled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('uploads multipart with the file and alt, then shows it in the library', async () => {
|
|
170
|
+
uploadReply = () => {
|
|
171
|
+
library = [asset()];
|
|
172
|
+
return HttpResponse.json(asset(), { status: 201 });
|
|
173
|
+
};
|
|
174
|
+
const { user, region } = await openImages();
|
|
175
|
+
await user.upload(await within(region).findByLabelText('Image file'), png());
|
|
176
|
+
await user.type(within(region).getByLabelText('Describe the image'), 'A cat');
|
|
177
|
+
await user.click(within(region).getByRole('button', { name: 'Upload image' }));
|
|
178
|
+
await waitFor(() => expect(uploads()).toHaveLength(1));
|
|
179
|
+
const form = uploads()[0].form!;
|
|
180
|
+
expect(form.file).toBe('cat.png');
|
|
181
|
+
expect(form.alt).toBe('A cat');
|
|
182
|
+
// what the helper really sent: a FormData body and NO forced JSON content type
|
|
183
|
+
expect(lastInit?.body).toBeInstanceOf(FormData);
|
|
184
|
+
expect((lastInit?.headers as Record<string, string>)['Content-Type']).toBeUndefined();
|
|
185
|
+
expect(await within(region).findByRole('img', { name: 'A cat' })).toBeInTheDocument();
|
|
186
|
+
expect(within(region).getByLabelText('Describe the image')).toHaveValue('');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("shows the server's reason when an upload is refused", async () => {
|
|
190
|
+
uploadReply = () => HttpResponse.json({ detail: 'that is not a PNG, JPEG, GIF or WebP image' }, { status: 400 });
|
|
191
|
+
const { user, region } = await openImages();
|
|
192
|
+
await user.upload(await within(region).findByLabelText('Image file'), png());
|
|
193
|
+
await user.type(within(region).getByLabelText('Describe the image'), 'x');
|
|
194
|
+
await user.click(within(region).getByRole('button', { name: 'Upload image' }));
|
|
195
|
+
expect(await within(region).findByRole('alert')).toHaveTextContent('not a PNG, JPEG, GIF or WebP');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('inserts an image into the message as its own paragraph', async () => {
|
|
199
|
+
library = [asset()];
|
|
200
|
+
const { user, region } = await openImages();
|
|
201
|
+
await user.type(within(region).getByLabelText('Message'), 'Hello there');
|
|
202
|
+
await user.click(await within(region).findByRole('button', { name: 'Insert A cat' }));
|
|
203
|
+
expect(within(region).getByLabelText('Message')).toHaveValue(
|
|
204
|
+
'Hello there\n\n',
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('saves a draft whose html has the <img> and whose text part says [Image: alt]', async () => {
|
|
209
|
+
library = [asset()];
|
|
210
|
+
const { user, region } = await openImages();
|
|
211
|
+
await user.type(within(region).getByLabelText('Subject'), 'Pics');
|
|
212
|
+
await user.type(within(region).getByLabelText('Message'), 'Look');
|
|
213
|
+
await user.click(await within(region).findByRole('button', { name: 'Insert A cat' }));
|
|
214
|
+
await user.click(within(region).getByRole('button', { name: 'Save as draft' }));
|
|
215
|
+
await waitFor(() => expect(hits.some((h) => h.method === 'POST' && h.path === '/drafts/')).toBe(true));
|
|
216
|
+
const body = hits.find((h) => h.method === 'POST' && h.path === '/drafts/')!.body;
|
|
217
|
+
expect(body.html).toBe(
|
|
218
|
+
'<p>Look</p><p><img src="https://cdn.test/crochets/img-1.png" alt="A cat" style="max-width:100%;height:auto;border:0"></p>',
|
|
219
|
+
);
|
|
220
|
+
expect(body.text).toBe('Look\n\n[Image: A cat]');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('deletes an image from the library', async () => {
|
|
224
|
+
library = [asset()];
|
|
225
|
+
const { user, region } = await openImages();
|
|
226
|
+
await user.click(await within(region).findByRole('button', { name: 'Delete A cat' }));
|
|
227
|
+
await waitFor(() => expect(hits.some((h) => h.method === 'DELETE' && h.path === '/assets/img-1/')).toBe(true));
|
|
228
|
+
await waitFor(() => expect(within(region).queryByRole('img', { name: 'A cat' })).not.toBeInTheDocument());
|
|
229
|
+
});
|
|
230
|
+
});
|
package/src/comms/api/_fetch.ts
CHANGED
|
@@ -47,7 +47,9 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
|
50
|
-
|
|
50
|
+
const isForm = typeof FormData !== 'undefined' && body instanceof FormData;
|
|
51
|
+
// A FormData body carries its own multipart boundary: the browser sets the header.
|
|
52
|
+
if (body !== undefined && !isForm) headers['Content-Type'] = 'application/json';
|
|
51
53
|
if (_tokenProvider) {
|
|
52
54
|
try {
|
|
53
55
|
const token = await _tokenProvider();
|
|
@@ -61,7 +63,7 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
|
|
|
61
63
|
method,
|
|
62
64
|
headers,
|
|
63
65
|
credentials: 'include',
|
|
64
|
-
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
66
|
+
...(body !== undefined ? { body: isForm ? (body as FormData) : JSON.stringify(body) } : {}),
|
|
65
67
|
});
|
|
66
68
|
|
|
67
69
|
const contentType = resp.headers.get('Content-Type') || '';
|
package/src/comms/api/comms.ts
CHANGED
|
@@ -17,6 +17,8 @@ import type {
|
|
|
17
17
|
ChangesResponse,
|
|
18
18
|
CommsMetrics,
|
|
19
19
|
CommsProgram,
|
|
20
|
+
AssetsQuery,
|
|
21
|
+
CommsAsset,
|
|
20
22
|
CommsSettings,
|
|
21
23
|
CommsSettingsPatch,
|
|
22
24
|
Draft,
|
|
@@ -65,6 +67,11 @@ export interface CommsApi {
|
|
|
65
67
|
listDrafts(query?: DraftsQuery): Promise<Paginated<Draft>>;
|
|
66
68
|
getDraft(id: string): Promise<Draft>;
|
|
67
69
|
createDraft(input: DraftCreate): Promise<Draft>;
|
|
70
|
+
|
|
71
|
+
// image library
|
|
72
|
+
listAssets(query?: AssetsQuery): Promise<Paginated<CommsAsset>>;
|
|
73
|
+
uploadAsset(file: File, alt: string): Promise<CommsAsset>;
|
|
74
|
+
deleteAsset(id: string): Promise<void>;
|
|
68
75
|
updateDraft(id: string, patch: DraftPatch): Promise<Draft>;
|
|
69
76
|
approveDraft(id: string): Promise<Draft>;
|
|
70
77
|
discardDraft(id: string): Promise<Draft>;
|
|
@@ -110,6 +117,14 @@ export function createCommsApi(slug: string): CommsApi {
|
|
|
110
117
|
listDrafts: (query = {}) => request(`${P}/drafts/`, { query: { ...query } }),
|
|
111
118
|
getDraft: (id) => request(draftPath(id)),
|
|
112
119
|
createDraft: (input) => request(`${P}/drafts/`, { method: 'POST', body: input }),
|
|
120
|
+
listAssets: (query = {}) => request(`${P}/assets/`, { query: { ...query } }),
|
|
121
|
+
uploadAsset: (file, alt) => {
|
|
122
|
+
const form = new FormData();
|
|
123
|
+
form.append('file', file);
|
|
124
|
+
form.append('alt', alt);
|
|
125
|
+
return request(`${P}/assets/`, { method: 'POST', body: form });
|
|
126
|
+
},
|
|
127
|
+
deleteAsset: (id) => request(`${P}/assets/${seg(id)}/`, { method: 'DELETE' }),
|
|
113
128
|
updateDraft: (id, patch) => request(draftPath(id), { method: 'PATCH', body: patch }),
|
|
114
129
|
approveDraft: (id) => request(draftPath(id, 'approve'), { method: 'POST' }),
|
|
115
130
|
discardDraft: (id) => request(draftPath(id, 'discard'), { method: 'POST' }),
|
|
@@ -12,7 +12,9 @@ import { Button, Input, Label, Textarea } from '@startsimpli/ui';
|
|
|
12
12
|
|
|
13
13
|
import type { CommsApi } from '../api/comms';
|
|
14
14
|
import { DEFAULT_POLL_MS, useCreateDraft, useDraft } from '../hooks/index';
|
|
15
|
+
import type { CommsAsset } from '../types/index';
|
|
15
16
|
import { DraftReview } from './DraftReview';
|
|
17
|
+
import { ImageLibrary } from './ImageLibrary';
|
|
16
18
|
import { ErrorNotice, Note, humanize, useFieldId } from './_shared';
|
|
17
19
|
|
|
18
20
|
export interface AnnouncementComposerProps {
|
|
@@ -24,15 +26,35 @@ type Mode = 'write' | 'notes';
|
|
|
24
26
|
|
|
25
27
|
const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
const IMAGE = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
|
|
30
|
+
const escapeAttr = (v: string) => escapeHtml(v).replace(/"/g, '"');
|
|
31
|
+
|
|
32
|
+
/** One paragraph's text, with `` turned into an <img> and everything else escaped. */
|
|
33
|
+
function inlineHtml(text: string): string {
|
|
34
|
+
let out = '';
|
|
35
|
+
let last = 0;
|
|
36
|
+
for (const m of text.matchAll(IMAGE)) {
|
|
37
|
+
out += escapeHtml(text.slice(last, m.index)).replace(/\n/g, '<br>');
|
|
38
|
+
out += `<img src="${escapeAttr(m[2])}" alt="${escapeAttr(m[1])}" style="max-width:100%;height:auto;border:0">`;
|
|
39
|
+
last = (m.index ?? 0) + m[0].length;
|
|
40
|
+
}
|
|
41
|
+
return out + escapeHtml(text.slice(last)).replace(/\n/g, '<br>');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Blank-line-separated paragraphs; a single newline is a line break; an image is ``. */
|
|
28
45
|
export function textToHtml(text: string): string {
|
|
29
46
|
return text
|
|
30
47
|
.trim()
|
|
31
48
|
.split(/\n{2,}/)
|
|
32
|
-
.map((p) => `<p>${
|
|
49
|
+
.map((p) => `<p>${inlineHtml(p.trim())}</p>`)
|
|
33
50
|
.join('');
|
|
34
51
|
}
|
|
35
52
|
|
|
53
|
+
/** The plain-text part: each image reads `[Image: alt]`. */
|
|
54
|
+
export function textToPlain(text: string): string {
|
|
55
|
+
return text.trim().replace(IMAGE, (_m, alt: string) => `[Image: ${alt}]`);
|
|
56
|
+
}
|
|
57
|
+
|
|
36
58
|
/** Follows one notes draft until the worker has finished writing it. */
|
|
37
59
|
function NotesProgress({
|
|
38
60
|
api,
|
|
@@ -72,6 +94,9 @@ export function AnnouncementComposer({ api, pollIntervalMs = DEFAULT_POLL_MS }:
|
|
|
72
94
|
else setStatus('Your draft is ready. Review it below.');
|
|
73
95
|
}, []);
|
|
74
96
|
|
|
97
|
+
const insertImage = (a: CommsAsset) =>
|
|
98
|
+
setMessage((m) => `${m.trimEnd()}${m.trim() ? '\n\n' : ''}`);
|
|
99
|
+
|
|
75
100
|
const canSave = mode === 'write' ? !!subject.trim() && !!message.trim() : !!notes.trim();
|
|
76
101
|
|
|
77
102
|
const submit = () => {
|
|
@@ -83,7 +108,7 @@ export function AnnouncementComposer({ api, pollIntervalMs = DEFAULT_POLL_MS }:
|
|
|
83
108
|
subject: subject.trim(),
|
|
84
109
|
...(preheader.trim() ? { preheader: preheader.trim() } : {}),
|
|
85
110
|
html: textToHtml(message),
|
|
86
|
-
text: message
|
|
111
|
+
text: textToPlain(message),
|
|
87
112
|
}
|
|
88
113
|
: { notes: notes.trim() };
|
|
89
114
|
create.mutate(input, {
|
|
@@ -140,6 +165,7 @@ export function AnnouncementComposer({ api, pollIntervalMs = DEFAULT_POLL_MS }:
|
|
|
140
165
|
<Textarea id={`${ids}-msg`} rows={8} value={message} onChange={(e) => setMessage(e.target.value)} />
|
|
141
166
|
<Note>Plain text: leave a blank line between paragraphs, and anything in angle brackets is shown as typed. {'{{first_name}}'} and {'{{app_url}}'} are filled in for each customer.</Note>
|
|
142
167
|
</div>
|
|
168
|
+
<ImageLibrary api={api} onInsert={insertImage} />
|
|
143
169
|
</>
|
|
144
170
|
) : (
|
|
145
171
|
<div className="space-y-1">
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The foundry's email image library, inside the announcement composer (bd
|
|
3
|
+
* startsim-ktuhz): upload a picture with its alt text (required), browse the
|
|
4
|
+
* library as thumbnails (server-paged), insert one into the message, delete one.
|
|
5
|
+
* The server decides what an image is (type sniffed, EXIF dropped) and which hosts
|
|
6
|
+
* survive the email sanitiser; this only collects, lists and hands back a url.
|
|
7
|
+
*/
|
|
8
|
+
import { useState, type ReactElement } from 'react';
|
|
9
|
+
import { Button, Input, Label } from '@startsimpli/ui';
|
|
10
|
+
|
|
11
|
+
import type { CommsApi } from '../api/comms';
|
|
12
|
+
import { useAssets, useDeleteAsset, useUploadAsset } from '../hooks/index';
|
|
13
|
+
import type { CommsAsset } from '../types/index';
|
|
14
|
+
import { ErrorNotice, Note, useFieldId } from './_shared';
|
|
15
|
+
|
|
16
|
+
export interface ImageLibraryProps {
|
|
17
|
+
api: CommsApi;
|
|
18
|
+
/** Called with the chosen image; the composer puts it in the message. */
|
|
19
|
+
onInsert: (asset: CommsAsset) => void;
|
|
20
|
+
pageSize?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ImageLibrary({ api, onInsert, pageSize = 12 }: ImageLibraryProps): ReactElement {
|
|
24
|
+
const ids = useFieldId('comms-images');
|
|
25
|
+
const [page, setPage] = useState(1);
|
|
26
|
+
const assets = useAssets(api, { page, page_size: pageSize });
|
|
27
|
+
const upload = useUploadAsset(api);
|
|
28
|
+
const remove = useDeleteAsset(api);
|
|
29
|
+
const [file, setFile] = useState<File | null>(null);
|
|
30
|
+
const [alt, setAlt] = useState('');
|
|
31
|
+
const [inputKey, setInputKey] = useState(0);
|
|
32
|
+
|
|
33
|
+
const results = assets.data?.results ?? [];
|
|
34
|
+
|
|
35
|
+
const submit = () => {
|
|
36
|
+
if (!file || !alt.trim()) return;
|
|
37
|
+
upload.mutate(
|
|
38
|
+
{ file, alt: alt.trim() },
|
|
39
|
+
{
|
|
40
|
+
onSuccess: () => {
|
|
41
|
+
setFile(null);
|
|
42
|
+
setAlt('');
|
|
43
|
+
setInputKey((k) => k + 1);
|
|
44
|
+
setPage(1);
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="space-y-3 rounded-md border p-3">
|
|
52
|
+
<p className="text-sm font-medium">Images</p>
|
|
53
|
+
<div className="flex flex-wrap items-end gap-3">
|
|
54
|
+
<div className="space-y-1">
|
|
55
|
+
<Label htmlFor={`${ids}-file`}>Image file</Label>
|
|
56
|
+
<input
|
|
57
|
+
key={inputKey}
|
|
58
|
+
id={`${ids}-file`}
|
|
59
|
+
type="file"
|
|
60
|
+
accept="image/png,image/jpeg,image/gif,image/webp"
|
|
61
|
+
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
|
62
|
+
className="block text-sm"
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
<div className="min-w-48 flex-1 space-y-1">
|
|
66
|
+
<Label htmlFor={`${ids}-alt`}>Describe the image</Label>
|
|
67
|
+
<Input id={`${ids}-alt`} value={alt} onChange={(e) => setAlt(e.target.value)} />
|
|
68
|
+
</div>
|
|
69
|
+
<Button type="button" disabled={!file || !alt.trim() || upload.isPending} onClick={submit}>
|
|
70
|
+
Upload image
|
|
71
|
+
</Button>
|
|
72
|
+
</div>
|
|
73
|
+
<Note>PNG, JPEG, GIF or WebP. The description is read aloud by screen readers and shown when images are blocked.</Note>
|
|
74
|
+
<ErrorNotice error={upload.error ?? remove.error ?? assets.error} />
|
|
75
|
+
|
|
76
|
+
{results.length ? (
|
|
77
|
+
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
78
|
+
{results.map((a) => (
|
|
79
|
+
<li key={a.id} className="space-y-1">
|
|
80
|
+
<img src={a.url} alt={a.alt} className="h-20 w-full rounded border object-cover" />
|
|
81
|
+
<div className="flex gap-1">
|
|
82
|
+
<Button type="button" size="sm" variant="outline" aria-label={`Insert ${a.alt}`} onClick={() => onInsert(a)}>
|
|
83
|
+
Insert
|
|
84
|
+
</Button>
|
|
85
|
+
<Button
|
|
86
|
+
type="button"
|
|
87
|
+
size="sm"
|
|
88
|
+
variant="ghost"
|
|
89
|
+
aria-label={`Delete ${a.alt}`}
|
|
90
|
+
disabled={remove.isPending}
|
|
91
|
+
onClick={() => remove.mutate(a.id)}
|
|
92
|
+
>
|
|
93
|
+
Delete
|
|
94
|
+
</Button>
|
|
95
|
+
</div>
|
|
96
|
+
</li>
|
|
97
|
+
))}
|
|
98
|
+
</ul>
|
|
99
|
+
) : (
|
|
100
|
+
<Note>{assets.isLoading ? 'Loading images…' : 'No images yet.'}</Note>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{assets.data && (assets.data.previous || assets.data.next) ? (
|
|
104
|
+
<div className="flex items-center gap-2">
|
|
105
|
+
<Button type="button" size="sm" variant="outline" disabled={!assets.data.previous} onClick={() => setPage((p) => p - 1)}>
|
|
106
|
+
Previous
|
|
107
|
+
</Button>
|
|
108
|
+
<Button type="button" size="sm" variant="outline" disabled={!assets.data.next} onClick={() => setPage((p) => p + 1)}>
|
|
109
|
+
Next
|
|
110
|
+
</Button>
|
|
111
|
+
</div>
|
|
112
|
+
) : null}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
package/src/comms/hooks/index.ts
CHANGED
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
|
|
20
20
|
import type { CommsApi } from '../api/comms';
|
|
21
21
|
import type {
|
|
22
|
+
AssetsQuery,
|
|
23
|
+
CommsAsset,
|
|
22
24
|
CommsMetrics,
|
|
23
25
|
CommsProgram,
|
|
24
26
|
CommsSettings,
|
|
@@ -48,6 +50,8 @@ export const commsKeys = {
|
|
|
48
50
|
['startsimpli', 'comms', slug, 'generation', key, step] as const,
|
|
49
51
|
preview: (slug: string, key: string, step: number) =>
|
|
50
52
|
['startsimpli', 'comms', slug, 'preview', key, step] as const,
|
|
53
|
+
assetsAll: (slug: string) => ['startsimpli', 'comms', slug, 'assets'] as const,
|
|
54
|
+
assets: (slug: string, q: AssetsQuery = {}) => ['startsimpli', 'comms', slug, 'assets', q] as const,
|
|
51
55
|
draftsAll: (slug: string) => ['startsimpli', 'comms', slug, 'drafts'] as const,
|
|
52
56
|
drafts: (slug: string, q: DraftsQuery = {}) => ['startsimpli', 'comms', slug, 'drafts', q] as const,
|
|
53
57
|
draft: (slug: string, id: string) => ['startsimpli', 'comms', slug, 'drafts', 'one', id] as const,
|
|
@@ -243,6 +247,28 @@ export function useSendUpdateNow(api: CommsApi): UseMutationResult<SendNowResult
|
|
|
243
247
|
return useDraftsWrite(api, (input: { notes?: string }) => api.sendUpdateNow(input));
|
|
244
248
|
}
|
|
245
249
|
|
|
250
|
+
// image library ---------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
export function useAssets(api: CommsApi, q: AssetsQuery): UseQueryResult<Paginated<CommsAsset>> {
|
|
253
|
+
return useQuery({ queryKey: commsKeys.assets(api.slug, q), queryFn: () => api.listAssets(q) });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function useAssetsWrite<R, V>(api: CommsApi, fn: (v: V) => Promise<R>) {
|
|
257
|
+
const qc = useQueryClient();
|
|
258
|
+
return useMutation<R, Error, V>({
|
|
259
|
+
mutationFn: fn,
|
|
260
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: commsKeys.assetsAll(api.slug) }),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function useUploadAsset(api: CommsApi): UseMutationResult<CommsAsset, Error, { file: File; alt: string }> {
|
|
265
|
+
return useAssetsWrite(api, ({ file, alt }: { file: File; alt: string }) => api.uploadAsset(file, alt));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function useDeleteAsset(api: CommsApi): UseMutationResult<void, Error, string> {
|
|
269
|
+
return useAssetsWrite(api, (id: string) => api.deleteAsset(id));
|
|
270
|
+
}
|
|
271
|
+
|
|
246
272
|
// results ------------------------------------------------------------------
|
|
247
273
|
|
|
248
274
|
/** Server-side pagination: page/page_size go to the server, never sliced here. */
|
package/src/comms/types/index.ts
CHANGED
|
@@ -218,6 +218,23 @@ export type DraftCreate =
|
|
|
218
218
|
| { subject: string; html: string; preheader?: string; text?: string }
|
|
219
219
|
| { notes: string };
|
|
220
220
|
|
|
221
|
+
/** One image in the foundry's email image library (comms/assets/). */
|
|
222
|
+
export interface CommsAsset {
|
|
223
|
+
id: string;
|
|
224
|
+
key: string;
|
|
225
|
+
/** Public CDN url; the only host the server's email sanitiser keeps. */
|
|
226
|
+
url: string;
|
|
227
|
+
filename: string;
|
|
228
|
+
content_type: string;
|
|
229
|
+
bytes: number;
|
|
230
|
+
width: number;
|
|
231
|
+
height: number;
|
|
232
|
+
alt: string;
|
|
233
|
+
created_at: string;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export type AssetsQuery = PageParams;
|
|
237
|
+
|
|
221
238
|
export type DraftPatch = Partial<Pick<Draft, 'subject' | 'preheader' | 'html' | 'text'>>;
|
|
222
239
|
|
|
223
240
|
export const NOTHING_CUSTOMER_FACING = 'nothing_customer_facing';
|