@carlonicora/nextjs-jsonapi 6.14.1 → 6.16.0
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/dist/{BlockNoteEditor-UIAX72XW.js → BlockNoteEditor-GWDYMS6R.js} +20 -20
- package/dist/{BlockNoteEditor-UIAX72XW.js.map → BlockNoteEditor-GWDYMS6R.js.map} +1 -1
- package/dist/{BlockNoteEditor-I3J6YYXK.mjs → BlockNoteEditor-HNP5UAJT.mjs} +5 -5
- package/dist/{BlockNoteViewer-HIUAJQRI.mjs → BlockNoteViewer-X4UEVI2A.mjs} +3 -3
- package/dist/{BlockNoteViewer-APBOPT4B.js → BlockNoteViewer-XYKFY77F.js} +4 -4
- package/dist/{BlockNoteViewer-APBOPT4B.js.map → BlockNoteViewer-XYKFY77F.js.map} +1 -1
- package/dist/EmailPurpose-DOsSqM5K.d.mts +3 -0
- package/dist/EmailPurpose-DOsSqM5K.d.ts +3 -0
- package/dist/billing/index.js +492 -492
- package/dist/billing/index.mjs +4 -4
- package/dist/{chunk-4XLDBOSE.js → chunk-A7Y5YW7Q.js} +1 -1
- package/dist/{chunk-4XLDBOSE.js.map → chunk-A7Y5YW7Q.js.map} +1 -1
- package/dist/{chunk-ZYSNJKOP.js → chunk-DFA566KY.js} +4 -4
- package/dist/{chunk-ZYSNJKOP.js.map → chunk-DFA566KY.js.map} +1 -1
- package/dist/{chunk-DJXSCARG.js → chunk-I5BQLY7V.js} +9 -9
- package/dist/{chunk-DJXSCARG.js.map → chunk-I5BQLY7V.js.map} +1 -1
- package/dist/{chunk-RFKSJIQB.mjs → chunk-KMPBSOFB.mjs} +138 -5
- package/dist/chunk-KMPBSOFB.mjs.map +1 -0
- package/dist/{chunk-UOTHRJD2.mjs → chunk-OKBJEIOP.mjs} +1 -1
- package/dist/chunk-OKBJEIOP.mjs.map +1 -0
- package/dist/{chunk-PMESKNKL.js → chunk-OO2DBP6L.js} +1106 -973
- package/dist/chunk-OO2DBP6L.js.map +1 -0
- package/dist/{chunk-HAW5PNJG.mjs → chunk-TY5YWBLL.mjs} +3 -3
- package/dist/{chunk-RT7XFGIN.js → chunk-UGDYZW4K.js} +1035 -997
- package/dist/chunk-UGDYZW4K.js.map +1 -0
- package/dist/{chunk-RYY7USSW.mjs → chunk-YEZGASBX.mjs} +50 -12
- package/dist/{chunk-RYY7USSW.mjs.map → chunk-YEZGASBX.mjs.map} +1 -1
- package/dist/{chunk-OUEYZOLB.mjs → chunk-YVKWACDP.mjs} +2 -2
- package/dist/client/index.js +5 -5
- package/dist/client/index.mjs +4 -4
- package/dist/components/editors/server.d.mts +7 -1
- package/dist/components/editors/server.d.ts +7 -1
- package/dist/components/editors/server.js +17 -4
- package/dist/components/editors/server.js.map +1 -1
- package/dist/components/editors/server.mjs +17 -4
- package/dist/components/editors/server.mjs.map +1 -1
- package/dist/components/index.d.mts +3 -2
- package/dist/components/index.d.ts +3 -2
- package/dist/components/index.js +5 -5
- package/dist/components/index.mjs +4 -4
- package/dist/contexts/index.js +5 -5
- package/dist/contexts/index.mjs +4 -4
- package/dist/core/index.d.mts +92 -7
- package/dist/core/index.d.ts +92 -7
- package/dist/core/index.js +7 -3
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +6 -2
- package/dist/{email-automation-enrollment.fields-BZvF14qx.d.mts → email-automation-enrollment.fields-CPsgxLif.d.mts} +11 -1
- package/dist/{email-automation-enrollment.fields-Cp3VMzFq.d.ts → email-automation-enrollment.fields-CVicSamF.d.ts} +11 -1
- package/dist/email-automations/index.d.mts +19 -5
- package/dist/email-automations/index.d.ts +19 -5
- package/dist/email-automations/index.js +356 -129
- package/dist/email-automations/index.js.map +1 -1
- package/dist/email-automations/index.mjs +306 -79
- package/dist/email-automations/index.mjs.map +1 -1
- package/dist/features/analytics/index.js +121 -121
- package/dist/features/analytics/index.mjs +4 -4
- package/dist/features/analytics/server.js +5 -5
- package/dist/features/analytics/server.mjs +3 -3
- package/dist/features/blog/index.js +151 -151
- package/dist/features/blog/index.mjs +4 -4
- package/dist/features/blog/server.js +17 -17
- package/dist/features/blog/server.mjs +2 -2
- package/dist/features/help/index.js +38 -38
- package/dist/features/help/index.mjs +4 -4
- package/dist/features/tokenusage/index.js +127 -127
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +4 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +8 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +7 -3
- package/dist/offline/index.js +14 -14
- package/dist/offline/index.mjs +1 -1
- package/dist/{referral-admin.interface-RVO-ZTJz.d.mts → referral-admin.interface-DTj4LzXx.d.mts} +3 -0
- package/dist/{referral-admin.interface-CnytdDaQ.d.ts → referral-admin.interface-DeSCw3Pp.d.ts} +3 -0
- package/dist/server/index.js +4 -4
- package/dist/server/index.mjs +3 -3
- package/messages/en.json +35 -3
- package/messages/it.json +35 -3
- package/package.json +1 -1
- package/src/components/editors/server/__tests__/renderBlockNoteMarkdown.spec.ts +45 -0
- package/src/components/editors/server/renderBlockNoteHTML.ts +2 -18
- package/src/components/editors/server/renderBlockNoteMarkdown.ts +10 -0
- package/src/components/editors/server/serverEditor.ts +25 -0
- package/src/components/editors/server-entry.ts +1 -0
- package/src/core/index.ts +1 -0
- package/src/core/registry/ModuleRegistry.ts +1 -0
- package/src/features/email-automation/EmailAutomationCandidateModule.ts +12 -0
- package/src/features/email-automation/EmailAutomationModule.ts +1 -0
- package/src/features/email-automation/components/__tests__/EmailAutomationEditor.spec.tsx +74 -9
- package/src/features/email-automation/components/forms/EmailAutomationAddPersonDialog.tsx +226 -0
- package/src/features/email-automation/components/forms/EmailAutomationEditor.tsx +23 -0
- package/src/features/email-automation/components/forms/EmailStepEditor.tsx +7 -3
- package/src/features/email-automation/components/index.ts +1 -0
- package/src/features/email-automation/components/lists/EmailAutomationRecipientsList.tsx +38 -20
- package/src/features/email-automation/components/lists/EmailAutomationsList.tsx +1 -0
- package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +9 -4
- package/src/features/email-automation/data/EmailAutomation.ts +9 -0
- package/src/features/email-automation/data/EmailAutomationCandidate.ts +66 -0
- package/src/features/email-automation/data/EmailAutomationCandidateInterface.ts +19 -0
- package/src/features/email-automation/data/EmailAutomationEnrollment.ts +16 -2
- package/src/features/email-automation/data/EmailAutomationEnrollmentInterface.ts +7 -0
- package/src/features/email-automation/data/EmailAutomationInterface.ts +3 -0
- package/src/features/email-automation/data/EmailAutomationService.ts +53 -1
- package/src/features/email-automation/data/EmailPurpose.ts +1 -0
- package/src/features/email-automation/data/__tests__/email-automation-models.spec.ts +70 -0
- package/src/features/email-automation/data/email-automation.fields.ts +1 -0
- package/src/features/email-automation/data/index.ts +2 -0
- package/src/features/email-automation/hooks/useEmailAutomationTableStructure.tsx +19 -0
- package/src/features/email-automation/index.ts +2 -0
- package/src/features/email-blast/EmailBlastModule.ts +1 -0
- package/src/features/email-blast/components/forms/EmailBlastEditor.tsx +33 -4
- package/src/features/email-blast/components/lists/EmailBlastsList.tsx +1 -0
- package/src/features/email-blast/data/EmailBlast.ts +10 -1
- package/src/features/email-blast/data/EmailBlastInterface.ts +3 -0
- package/src/features/email-blast/data/__tests__/EmailBlast.spec.ts +18 -0
- package/src/features/email-blast/data/email-blast.fields.ts +1 -0
- package/src/features/email-blast/hooks/useEmailBlastTableStructure.tsx +15 -0
- package/dist/chunk-PMESKNKL.js.map +0 -1
- package/dist/chunk-RFKSJIQB.mjs.map +0 -1
- package/dist/chunk-RT7XFGIN.js.map +0 -1
- package/dist/chunk-UOTHRJD2.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-I3J6YYXK.mjs.map → BlockNoteEditor-HNP5UAJT.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-HIUAJQRI.mjs.map → BlockNoteViewer-X4UEVI2A.mjs.map} +0 -0
- /package/dist/{chunk-HAW5PNJG.mjs.map → chunk-TY5YWBLL.mjs.map} +0 -0
- /package/dist/{chunk-OUEYZOLB.mjs.map → chunk-YVKWACDP.mjs.map} +0 -0
package/messages/en.json
CHANGED
|
@@ -1212,7 +1212,8 @@
|
|
|
1212
1212
|
"status": "Status",
|
|
1213
1213
|
"steps": "Emails",
|
|
1214
1214
|
"trigger": "Trigger",
|
|
1215
|
-
"unsubscribed": "Left"
|
|
1215
|
+
"unsubscribed": "Left",
|
|
1216
|
+
"purpose": "Purpose"
|
|
1216
1217
|
},
|
|
1217
1218
|
"status": {
|
|
1218
1219
|
"off": "Off",
|
|
@@ -1236,6 +1237,27 @@
|
|
|
1236
1237
|
"exited": "Exited",
|
|
1237
1238
|
"failed": "Failed",
|
|
1238
1239
|
"unsubscribed": "Unsubscribed"
|
|
1240
|
+
},
|
|
1241
|
+
"addPerson": "Add person",
|
|
1242
|
+
"addPersonNoSteps": "Add an email first.",
|
|
1243
|
+
"addDialog": {
|
|
1244
|
+
"title": "Add a person",
|
|
1245
|
+
"search": "Search by name or email",
|
|
1246
|
+
"empty": "Nobody found.",
|
|
1247
|
+
"kind": {
|
|
1248
|
+
"user": "User",
|
|
1249
|
+
"waitlist": "Waitlist"
|
|
1250
|
+
},
|
|
1251
|
+
"reason": {
|
|
1252
|
+
"already_enrolled": "Already in this automation",
|
|
1253
|
+
"opted_out": "Unsubscribed from marketing",
|
|
1254
|
+
"no_consent": "No marketing consent"
|
|
1255
|
+
},
|
|
1256
|
+
"added": "{email} added",
|
|
1257
|
+
"groups": {
|
|
1258
|
+
"addable": "Can be added",
|
|
1259
|
+
"blocked": "Can't be added"
|
|
1260
|
+
}
|
|
1239
1261
|
}
|
|
1240
1262
|
},
|
|
1241
1263
|
"step": {
|
|
@@ -1283,7 +1305,8 @@
|
|
|
1283
1305
|
"sentAt": "Sent",
|
|
1284
1306
|
"sent": "Sent to",
|
|
1285
1307
|
"failed": "Failed",
|
|
1286
|
-
"unsubscribed": "Unsubscribed"
|
|
1308
|
+
"unsubscribed": "Unsubscribed",
|
|
1309
|
+
"purpose": "Purpose"
|
|
1287
1310
|
},
|
|
1288
1311
|
"status": {
|
|
1289
1312
|
"draft": "Draft",
|
|
@@ -1309,7 +1332,8 @@
|
|
|
1309
1332
|
}
|
|
1310
1333
|
},
|
|
1311
1334
|
"variablesHint": "You can use:",
|
|
1312
|
-
"unsubscribeHint": "Every email ends with a link to stop all marketing emails."
|
|
1335
|
+
"unsubscribeHint": "Every email ends with a link to stop all marketing emails.",
|
|
1336
|
+
"serviceHint": "Service emails go to every user and carry no unsubscribe link."
|
|
1313
1337
|
},
|
|
1314
1338
|
"detail": {
|
|
1315
1339
|
"recipients": "Recipients",
|
|
@@ -2190,5 +2214,13 @@
|
|
|
2190
2214
|
"hint": "Did not receive the email? Check your spam folder or try again later.",
|
|
2191
2215
|
"title": "You are on the list!"
|
|
2192
2216
|
}
|
|
2217
|
+
},
|
|
2218
|
+
"emailPurpose": {
|
|
2219
|
+
"label": "Purpose",
|
|
2220
|
+
"placeholder": "Choose a purpose",
|
|
2221
|
+
"error": "Choose a purpose",
|
|
2222
|
+
"service": "Service",
|
|
2223
|
+
"marketing": "Marketing",
|
|
2224
|
+
"serviceWarning": "Only information about their waitlist spot or account. Anything that promotes the product is Marketing."
|
|
2193
2225
|
}
|
|
2194
2226
|
}
|
package/messages/it.json
CHANGED
|
@@ -1212,7 +1212,8 @@
|
|
|
1212
1212
|
"status": "Stato",
|
|
1213
1213
|
"steps": "Email",
|
|
1214
1214
|
"trigger": "Evento",
|
|
1215
|
-
"unsubscribed": "Disiscritti"
|
|
1215
|
+
"unsubscribed": "Disiscritti",
|
|
1216
|
+
"purpose": "Scopo"
|
|
1216
1217
|
},
|
|
1217
1218
|
"status": {
|
|
1218
1219
|
"off": "Spenta",
|
|
@@ -1236,6 +1237,27 @@
|
|
|
1236
1237
|
"exited": "Usciti",
|
|
1237
1238
|
"failed": "Non riusciti",
|
|
1238
1239
|
"unsubscribed": "Disiscritti"
|
|
1240
|
+
},
|
|
1241
|
+
"addPerson": "Aggiungi persona",
|
|
1242
|
+
"addPersonNoSteps": "Aggiungi prima un'email.",
|
|
1243
|
+
"addDialog": {
|
|
1244
|
+
"title": "Aggiungi una persona",
|
|
1245
|
+
"search": "Cerca per nome o email",
|
|
1246
|
+
"empty": "Nessuno trovato.",
|
|
1247
|
+
"kind": {
|
|
1248
|
+
"user": "Utente",
|
|
1249
|
+
"waitlist": "Lista d'attesa"
|
|
1250
|
+
},
|
|
1251
|
+
"reason": {
|
|
1252
|
+
"already_enrolled": "Già in questa automazione",
|
|
1253
|
+
"opted_out": "Disiscritto dal marketing",
|
|
1254
|
+
"no_consent": "Nessun consenso marketing"
|
|
1255
|
+
},
|
|
1256
|
+
"added": "{email} aggiunto",
|
|
1257
|
+
"groups": {
|
|
1258
|
+
"addable": "Possono essere aggiunti",
|
|
1259
|
+
"blocked": "Non possono essere aggiunti"
|
|
1260
|
+
}
|
|
1239
1261
|
}
|
|
1240
1262
|
},
|
|
1241
1263
|
"step": {
|
|
@@ -1283,7 +1305,8 @@
|
|
|
1283
1305
|
"sentAt": "Inviata",
|
|
1284
1306
|
"sent": "Inviata a",
|
|
1285
1307
|
"failed": "Non riuscite",
|
|
1286
|
-
"unsubscribed": "Disiscritti"
|
|
1308
|
+
"unsubscribed": "Disiscritti",
|
|
1309
|
+
"purpose": "Scopo"
|
|
1287
1310
|
},
|
|
1288
1311
|
"status": {
|
|
1289
1312
|
"draft": "Bozza",
|
|
@@ -1309,7 +1332,8 @@
|
|
|
1309
1332
|
}
|
|
1310
1333
|
},
|
|
1311
1334
|
"variablesHint": "Puoi usare:",
|
|
1312
|
-
"unsubscribeHint": "Ogni email finisce con un link per non ricevere più email di marketing."
|
|
1335
|
+
"unsubscribeHint": "Ogni email finisce con un link per non ricevere più email di marketing.",
|
|
1336
|
+
"serviceHint": "Le email di servizio arrivano a tutti gli utenti e non hanno il link di disiscrizione."
|
|
1313
1337
|
},
|
|
1314
1338
|
"detail": {
|
|
1315
1339
|
"recipients": "Destinatari",
|
|
@@ -2190,5 +2214,13 @@
|
|
|
2190
2214
|
"hint": "Non hai ricevuto l'email? Controlla lo spam o riprova più tardi.",
|
|
2191
2215
|
"title": "Sei in lista!"
|
|
2192
2216
|
}
|
|
2217
|
+
},
|
|
2218
|
+
"emailPurpose": {
|
|
2219
|
+
"label": "Scopo",
|
|
2220
|
+
"placeholder": "Scegli lo scopo",
|
|
2221
|
+
"error": "Scegli lo scopo",
|
|
2222
|
+
"service": "Servizio",
|
|
2223
|
+
"marketing": "Marketing",
|
|
2224
|
+
"serviceWarning": "Solo informazioni sul loro posto in lista d'attesa o sul loro account. Tutto ciò che promuove il prodotto è Marketing."
|
|
2193
2225
|
}
|
|
2194
2226
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
// The server render runs in Node, where there is no window or document of our own:
|
|
3
|
+
// BlockNote's ServerBlockNoteEditor brings its JSDOM.
|
|
4
|
+
import type { PartialBlock } from "@blocknote/core";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { renderBlockNoteHTML } from "../renderBlockNoteHTML";
|
|
7
|
+
import { renderBlockNoteMarkdown } from "../renderBlockNoteMarkdown";
|
|
8
|
+
|
|
9
|
+
const blocks: PartialBlock[] = [
|
|
10
|
+
{ type: "heading", props: { level: 2 }, content: "Think of the Campaign as the lid on the box" },
|
|
11
|
+
{ type: "paragraph", content: "One Campaign is one story arc." },
|
|
12
|
+
{ type: "bulletListItem", content: "Characters" },
|
|
13
|
+
{
|
|
14
|
+
type: "paragraph",
|
|
15
|
+
content: [{ type: "link", href: "https://wyrd.li/help", content: "help" }],
|
|
16
|
+
},
|
|
17
|
+
{ type: "image", props: { url: "https://wyrd.li/marketing/og.png", caption: "Wyrdli" } },
|
|
18
|
+
{ type: "image", props: { url: "https://wyrd.li/marketing/plain.png" } },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
describe("renderBlockNoteMarkdown", () => {
|
|
22
|
+
it("renders headings, paragraphs, bullet lists and links", async () => {
|
|
23
|
+
const markdown = await renderBlockNoteMarkdown(blocks);
|
|
24
|
+
|
|
25
|
+
expect(markdown).toContain("## Think of the Campaign as the lid on the box");
|
|
26
|
+
expect(markdown).toContain("One Campaign is one story arc.");
|
|
27
|
+
expect(markdown).toMatch(/^\s*[-*+] Characters\s*$/m);
|
|
28
|
+
expect(markdown).toContain("[help](https://wyrd.li/help)");
|
|
29
|
+
// blocksToMarkdownLossy keeps a captioned image as an HTML <figure> (valid
|
|
30
|
+
// CommonMark HTML block); an image with no caption becomes .
|
|
31
|
+
expect(markdown).toMatch(/<img[^>]*src="https:\/\/wyrd\.li\/marketing\/og\.png"/);
|
|
32
|
+
expect(markdown).toContain("<figcaption>Wyrdli</figcaption>");
|
|
33
|
+
expect(markdown).toMatch(/!\[[^\]]*\]\(https:\/\/wyrd\.li\/marketing\/plain\.png\)/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("two overlapping calls both resolve and leave no JSDOM window on globalThis", async () => {
|
|
37
|
+
const before = typeof (globalThis as { window?: unknown }).window;
|
|
38
|
+
|
|
39
|
+
const [markdown, html] = await Promise.all([renderBlockNoteMarkdown(blocks), renderBlockNoteHTML(blocks)]);
|
|
40
|
+
|
|
41
|
+
expect(markdown).toContain("One Campaign is one story arc.");
|
|
42
|
+
expect(html).toContain("One Campaign is one story arc.");
|
|
43
|
+
expect(typeof (globalThis as { window?: unknown }).window).toBe(before);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -1,26 +1,10 @@
|
|
|
1
1
|
import type { PartialBlock } from "@blocknote/core";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
// Default schema only, the same one BlockNoteViewer uses: the editor's mention
|
|
5
|
-
// and diff inline specs are React specs from "use client" modules (next/link,
|
|
6
|
-
// hover cards), so they cannot run here.
|
|
7
|
-
let editor: ServerBlockNoteEditor | undefined;
|
|
8
|
-
|
|
9
|
-
// ServerBlockNoteEditor swaps globalThis.window/document for its JSDOM around
|
|
10
|
-
// each call and restores them after an await. Two overlapping calls would
|
|
11
|
-
// restore each other's values and could leave the JSDOM window on the server's
|
|
12
|
-
// globals, so calls run one at a time.
|
|
13
|
-
let queue: Promise<unknown> = Promise.resolve();
|
|
2
|
+
import { runWithServerEditor } from "./serverEditor";
|
|
14
3
|
|
|
15
4
|
/**
|
|
16
5
|
* Renders a BlockNote blocks array to the HTML the editor itself would render,
|
|
17
6
|
* through BlockNote's own server editor (`blocksToFullHTML`). Server only.
|
|
18
7
|
*/
|
|
19
8
|
export function renderBlockNoteHTML(blocks: PartialBlock[]): Promise<string> {
|
|
20
|
-
|
|
21
|
-
editor ??= ServerBlockNoteEditor.create();
|
|
22
|
-
return editor.blocksToFullHTML(blocks);
|
|
23
|
-
});
|
|
24
|
-
queue = run.catch(() => undefined);
|
|
25
|
-
return run;
|
|
9
|
+
return runWithServerEditor((editor) => editor.blocksToFullHTML(blocks));
|
|
26
10
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PartialBlock } from "@blocknote/core";
|
|
2
|
+
import { runWithServerEditor } from "./serverEditor";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Renders a BlockNote blocks array to Markdown through BlockNote's own server
|
|
6
|
+
* editor (`blocksToMarkdownLossy`). Server only.
|
|
7
|
+
*/
|
|
8
|
+
export function renderBlockNoteMarkdown(blocks: PartialBlock[]): Promise<string> {
|
|
9
|
+
return runWithServerEditor((editor) => editor.blocksToMarkdownLossy(blocks));
|
|
10
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ServerBlockNoteEditor } from "@blocknote/server-util";
|
|
2
|
+
|
|
3
|
+
// Default schema only, the same one BlockNoteViewer uses: the editor's mention
|
|
4
|
+
// and diff inline specs are React specs from "use client" modules (next/link,
|
|
5
|
+
// hover cards), so they cannot run here.
|
|
6
|
+
let editor: ServerBlockNoteEditor | undefined;
|
|
7
|
+
|
|
8
|
+
// ServerBlockNoteEditor swaps globalThis.window/document for its JSDOM around
|
|
9
|
+
// each call and restores them after an await. Two overlapping calls would
|
|
10
|
+
// restore each other's values and could leave the JSDOM window on the server's
|
|
11
|
+
// globals, so calls run one at a time.
|
|
12
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Runs `fn` with the shared server editor, one call at a time. Server only.
|
|
16
|
+
* Internal to the library: not exported from the barrel.
|
|
17
|
+
*/
|
|
18
|
+
export function runWithServerEditor<T>(fn: (editor: ServerBlockNoteEditor) => T | Promise<T>): Promise<T> {
|
|
19
|
+
const run = queue.then(() => {
|
|
20
|
+
editor ??= ServerBlockNoteEditor.create();
|
|
21
|
+
return fn(editor);
|
|
22
|
+
});
|
|
23
|
+
queue = run.catch(() => undefined);
|
|
24
|
+
return run;
|
|
25
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -81,6 +81,7 @@ export * from "../features/email-automation/EmailAutomationModule";
|
|
|
81
81
|
export * from "../features/email-automation/EmailAutomationStepModule";
|
|
82
82
|
export * from "../features/email-automation/EmailAutomationEnrollmentModule";
|
|
83
83
|
export * from "../features/email-automation/EmailAutomationTriggerModule";
|
|
84
|
+
export * from "../features/email-automation/EmailAutomationCandidateModule";
|
|
84
85
|
// Email automation data (models, services, interfaces, fields): server-safe, so a
|
|
85
86
|
// server page can wrap EmailAutomationService.findOne in cache(). The client-marked
|
|
86
87
|
// ./email-automations entry carries only components, contexts and hooks.
|
|
@@ -49,6 +49,7 @@ export interface FoundationModuleDefinitions {
|
|
|
49
49
|
EmailAutomationStep: ModuleWithPermissions;
|
|
50
50
|
EmailAutomationEnrollment: ModuleWithPermissions;
|
|
51
51
|
EmailAutomationTrigger: ModuleWithPermissions;
|
|
52
|
+
EmailAutomationCandidate: ModuleWithPermissions;
|
|
52
53
|
// Email blast modules
|
|
53
54
|
EmailBlast: ModuleWithPermissions;
|
|
54
55
|
EmailBlastDelivery: ModuleWithPermissions;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { UserPlusIcon } from "lucide-react";
|
|
2
|
+
import { ModuleFactory } from "../../permissions";
|
|
3
|
+
import { EmailAutomationCandidate } from "./data/EmailAutomationCandidate";
|
|
4
|
+
|
|
5
|
+
export const EmailAutomationCandidateModule = (factory: ModuleFactory) =>
|
|
6
|
+
factory({
|
|
7
|
+
moduleId: "5c3e9a71-2b84-4f06-9d1e-7a4b6c8e0f25",
|
|
8
|
+
pageUrl: undefined,
|
|
9
|
+
name: "email-automation-candidates",
|
|
10
|
+
model: EmailAutomationCandidate,
|
|
11
|
+
icon: UserPlusIcon,
|
|
12
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
1
|
+
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
2
2
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
|
|
4
4
|
import { configureI18n } from "../../../../i18n";
|
|
@@ -22,31 +22,51 @@ vi.mock("../../data/EmailAutomationService", () => ({
|
|
|
22
22
|
|
|
23
23
|
// Base UI renders a closed Select's options nowhere in the DOM, so FormSelect
|
|
24
24
|
// is replaced by a stub that prints its label, its empty option and its
|
|
25
|
-
// values.
|
|
25
|
+
// values. The stub keeps the real FormFieldWrapper (so the description and the
|
|
26
|
+
// validation error render as in production) and turns each value into a
|
|
27
|
+
// button that sets the field. Everything else in the barrel stays real.
|
|
26
28
|
vi.mock("../../../../components", async () => {
|
|
27
29
|
const actual = await vi.importActual<typeof import("../../../../components")>("../../../../components");
|
|
28
30
|
return {
|
|
29
31
|
...actual,
|
|
30
32
|
FormSelect: (props: {
|
|
33
|
+
form: any;
|
|
31
34
|
id: string;
|
|
32
35
|
name?: string;
|
|
36
|
+
description?: string;
|
|
33
37
|
placeholder?: string;
|
|
34
38
|
allowEmpty?: boolean;
|
|
39
|
+
isRequired?: boolean;
|
|
35
40
|
values: { id: string; text: string }[];
|
|
36
41
|
}) => (
|
|
37
42
|
<div data-testid={`form-select-${props.id}`}>
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
<actual.FormFieldWrapper
|
|
44
|
+
form={props.form}
|
|
45
|
+
name={props.id}
|
|
46
|
+
label={props.name}
|
|
47
|
+
description={props.description}
|
|
48
|
+
isRequired={props.isRequired}
|
|
49
|
+
>
|
|
50
|
+
{(field: any) => (
|
|
51
|
+
<>
|
|
52
|
+
{props.allowEmpty && <span data-value="">{props.placeholder}</span>}
|
|
53
|
+
{props.values.map((value) => (
|
|
54
|
+
<button key={value.id} type="button" data-value={value.id} onClick={() => field.onChange(value.id)}>
|
|
55
|
+
{value.text}
|
|
56
|
+
</button>
|
|
57
|
+
))}
|
|
58
|
+
</>
|
|
59
|
+
)}
|
|
60
|
+
</actual.FormFieldWrapper>
|
|
45
61
|
</div>
|
|
46
62
|
),
|
|
47
63
|
};
|
|
48
64
|
});
|
|
49
65
|
|
|
66
|
+
// The sheet is portaled into document.body and its label is not tied to the
|
|
67
|
+
// input, so the name field is found by the field name react-hook-form sets.
|
|
68
|
+
const nameInput = () => document.querySelector<HTMLInputElement>('input[name="name"]')!;
|
|
69
|
+
|
|
50
70
|
const TRIGGERS = [
|
|
51
71
|
{ id: "waitlist-confirmed", key: "waitlist-confirmed", label: "Waitlist confirmed" },
|
|
52
72
|
{ id: "registration-completed", key: "registration-completed", label: "Registration completed" },
|
|
@@ -101,4 +121,49 @@ describe("EmailAutomationEditor", () => {
|
|
|
101
121
|
// The option values are the trigger keys, the visible text the plain label.
|
|
102
122
|
expect(within(triggerSelect).getByText("Waitlist confirmed")).toHaveAttribute("data-value", "waitlist-confirmed");
|
|
103
123
|
});
|
|
124
|
+
it("purpose select is required on create", async () => {
|
|
125
|
+
render(<EmailAutomationEditor forceShow dialogOpen />);
|
|
126
|
+
|
|
127
|
+
const purposeSelect = await screen.findByTestId("form-select-purpose");
|
|
128
|
+
expect(within(purposeSelect).getByText("emailPurpose.service")).toHaveAttribute("data-value", "service");
|
|
129
|
+
expect(within(purposeSelect).getByText("emailPurpose.marketing")).toHaveAttribute("data-value", "marketing");
|
|
130
|
+
|
|
131
|
+
fireEvent.change(nameInput(), {
|
|
132
|
+
target: { value: "Welcome" },
|
|
133
|
+
});
|
|
134
|
+
fireEvent.click(screen.getByTestId("modal-button-create"));
|
|
135
|
+
|
|
136
|
+
expect(await within(purposeSelect).findByText("emailPurpose.error")).toBeInTheDocument();
|
|
137
|
+
expect(mocks.create).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("service purpose shows the warning", async () => {
|
|
141
|
+
render(<EmailAutomationEditor forceShow dialogOpen />);
|
|
142
|
+
|
|
143
|
+
const purposeSelect = await screen.findByTestId("form-select-purpose");
|
|
144
|
+
expect(within(purposeSelect).queryByText("emailPurpose.serviceWarning")).not.toBeInTheDocument();
|
|
145
|
+
|
|
146
|
+
fireEvent.click(within(purposeSelect).getByText("emailPurpose.service"));
|
|
147
|
+
expect(await within(purposeSelect).findByText("emailPurpose.serviceWarning")).toBeInTheDocument();
|
|
148
|
+
|
|
149
|
+
fireEvent.click(within(purposeSelect).getByText("emailPurpose.marketing"));
|
|
150
|
+
await waitFor(() =>
|
|
151
|
+
expect(within(purposeSelect).queryByText("emailPurpose.serviceWarning")).not.toBeInTheDocument(),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("payload carries purpose", async () => {
|
|
156
|
+
mocks.create.mockResolvedValue({ id: "a1" });
|
|
157
|
+
render(<EmailAutomationEditor forceShow dialogOpen />);
|
|
158
|
+
|
|
159
|
+
const purposeSelect = await screen.findByTestId("form-select-purpose");
|
|
160
|
+
fireEvent.change(nameInput(), {
|
|
161
|
+
target: { value: "Welcome" },
|
|
162
|
+
});
|
|
163
|
+
fireEvent.click(within(purposeSelect).getByText("emailPurpose.service"));
|
|
164
|
+
fireEvent.click(screen.getByTestId("modal-button-create"));
|
|
165
|
+
|
|
166
|
+
await waitFor(() => expect(mocks.create).toHaveBeenCalled());
|
|
167
|
+
expect(mocks.create.mock.calls[0][0]).toMatchObject({ name: "Welcome", purpose: "service" });
|
|
168
|
+
});
|
|
104
169
|
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { UserPlusIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { v4 } from "uuid";
|
|
7
|
+
import { errorToast } from "../../../../components";
|
|
8
|
+
import { Modules } from "../../../../core";
|
|
9
|
+
import { DataListRetriever, useDataListRetriever, useDebounce } from "../../../../hooks";
|
|
10
|
+
import {
|
|
11
|
+
Avatar,
|
|
12
|
+
AvatarFallback,
|
|
13
|
+
Badge,
|
|
14
|
+
Button,
|
|
15
|
+
Command,
|
|
16
|
+
CommandEmpty,
|
|
17
|
+
CommandGroup,
|
|
18
|
+
CommandInput,
|
|
19
|
+
CommandItem,
|
|
20
|
+
CommandList,
|
|
21
|
+
Dialog,
|
|
22
|
+
DialogContent,
|
|
23
|
+
DialogHeader,
|
|
24
|
+
DialogTitle,
|
|
25
|
+
DialogTrigger,
|
|
26
|
+
} from "../../../../shadcnui";
|
|
27
|
+
import { getInitials } from "../../../../utils/getInitials";
|
|
28
|
+
import { showToast } from "../../../../utils/toast";
|
|
29
|
+
import { EmailAutomationCandidateInterface } from "../../data/EmailAutomationCandidateInterface";
|
|
30
|
+
import { EmailAutomationService } from "../../data/EmailAutomationService";
|
|
31
|
+
|
|
32
|
+
export type EmailAutomationAddPersonDialogProps = {
|
|
33
|
+
automationId: string;
|
|
34
|
+
disabled: boolean;
|
|
35
|
+
onAdded: () => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* "Add person" on the Recipients tab: searches users and waitlist entries and
|
|
40
|
+
* enrols the picked one. The API decides who is addable and why not; this
|
|
41
|
+
* dialog only shows it (addable rows first, then dimmed rows with the reason).
|
|
42
|
+
*/
|
|
43
|
+
export function EmailAutomationAddPersonDialog({
|
|
44
|
+
automationId,
|
|
45
|
+
disabled,
|
|
46
|
+
onAdded,
|
|
47
|
+
}: EmailAutomationAddPersonDialogProps) {
|
|
48
|
+
const t = useTranslations();
|
|
49
|
+
const [open, setOpen] = useState<boolean>(false);
|
|
50
|
+
const searchTermRef = useRef<string>("");
|
|
51
|
+
const [searchTerm, setSearchTerm] = useState<string>("");
|
|
52
|
+
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
|
53
|
+
|
|
54
|
+
const data: DataListRetriever<EmailAutomationCandidateInterface> = useDataListRetriever({
|
|
55
|
+
ready: open,
|
|
56
|
+
retriever: (params) => EmailAutomationService.findCandidates(params),
|
|
57
|
+
retrieverParams: { automationId },
|
|
58
|
+
module: Modules.EmailAutomationCandidate,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (open) data.setReady(true);
|
|
63
|
+
}, [data, open]);
|
|
64
|
+
|
|
65
|
+
const refreshList = useCallback(
|
|
66
|
+
async (searchedTerm: string) => {
|
|
67
|
+
if (searchedTerm === searchTermRef.current) return;
|
|
68
|
+
searchTermRef.current = searchedTerm;
|
|
69
|
+
await data.search(searchedTerm);
|
|
70
|
+
},
|
|
71
|
+
[data],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const updateSearchTerm = useDebounce(refreshList, 500);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (open) updateSearchTerm(searchTerm);
|
|
78
|
+
}, [open, searchTerm]);
|
|
79
|
+
|
|
80
|
+
useEffect(() => () => updateSearchTerm.cancel(), [updateSearchTerm]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!data.next || !sentinelRef.current) return;
|
|
84
|
+
const observer = new IntersectionObserver(
|
|
85
|
+
(entries) => {
|
|
86
|
+
if (entries[0]?.isIntersecting) data.next?.();
|
|
87
|
+
},
|
|
88
|
+
{ threshold: 0.1, rootMargin: "200px" },
|
|
89
|
+
);
|
|
90
|
+
observer.observe(sentinelRef.current);
|
|
91
|
+
return () => observer.disconnect();
|
|
92
|
+
}, [data.next]);
|
|
93
|
+
|
|
94
|
+
const handleOpenChange = (next: boolean) => {
|
|
95
|
+
setOpen(next);
|
|
96
|
+
if (!next) {
|
|
97
|
+
updateSearchTerm.cancel();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Reopening starts from an empty search and re-reads the list: someone added
|
|
102
|
+
// last time is now "already in this automation".
|
|
103
|
+
setSearchTerm("");
|
|
104
|
+
if (data.isLoaded) {
|
|
105
|
+
if (searchTermRef.current !== "") {
|
|
106
|
+
searchTermRef.current = "";
|
|
107
|
+
void data.search("");
|
|
108
|
+
} else {
|
|
109
|
+
void data.refresh();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const add = async (candidate: EmailAutomationCandidateInterface) => {
|
|
115
|
+
if (!candidate.addable) return;
|
|
116
|
+
try {
|
|
117
|
+
await EmailAutomationService.addEnrollment({
|
|
118
|
+
automationId,
|
|
119
|
+
input: { id: v4(), recipientType: candidate.recipientType, recipientId: candidate.recipientId },
|
|
120
|
+
});
|
|
121
|
+
setOpen(false);
|
|
122
|
+
showToast(t("emailAutomations.recipients.addDialog.added", { email: candidate.email }));
|
|
123
|
+
onAdded();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
errorToast({ title: t("common.errors.error"), error });
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const candidates = data.data ?? [];
|
|
130
|
+
const addable = candidates.filter((candidate) => candidate.addable);
|
|
131
|
+
const blocked = candidates.filter((candidate) => !candidate.addable);
|
|
132
|
+
|
|
133
|
+
const renderCandidate = (candidate: EmailAutomationCandidateInterface) => (
|
|
134
|
+
<CommandItem
|
|
135
|
+
key={candidate.id}
|
|
136
|
+
className="cursor-pointer hover:bg-muted data-selected:hover:bg-muted bg-transparent data-selected:bg-transparent"
|
|
137
|
+
value={candidate.id}
|
|
138
|
+
disabled={!candidate.addable}
|
|
139
|
+
data-testid={`email-automation-candidate-${candidate.recipientId}`}
|
|
140
|
+
data-addable={candidate.addable ? "true" : "false"}
|
|
141
|
+
onSelect={() => void add(candidate)}
|
|
142
|
+
>
|
|
143
|
+
<div className="flex min-w-0 flex-1 flex-row items-center px-4 py-1">
|
|
144
|
+
<div className="*:ring-border *:ring-1">
|
|
145
|
+
<Avatar className="h-6 w-6">
|
|
146
|
+
<AvatarFallback>{getInitials(candidate.name || candidate.email)}</AvatarFallback>
|
|
147
|
+
</Avatar>
|
|
148
|
+
</div>
|
|
149
|
+
<div className="ms-5 me-3 flex min-w-0 flex-1 flex-col">
|
|
150
|
+
{candidate.name ? (
|
|
151
|
+
<>
|
|
152
|
+
<div className="truncate text-sm font-medium">{candidate.name}</div>
|
|
153
|
+
<div className="truncate text-xs font-normal">{candidate.email}</div>
|
|
154
|
+
</>
|
|
155
|
+
) : (
|
|
156
|
+
<div className="truncate text-sm font-medium">{candidate.email}</div>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
{candidate.addable ? (
|
|
160
|
+
<Badge
|
|
161
|
+
variant={candidate.recipientType === "waitlist" ? "softBlue" : "softGray"}
|
|
162
|
+
className="ms-auto shrink-0"
|
|
163
|
+
>
|
|
164
|
+
{t(`emailAutomations.recipients.addDialog.kind.${candidate.recipientType}`)}
|
|
165
|
+
</Badge>
|
|
166
|
+
) : (
|
|
167
|
+
candidate.reason && (
|
|
168
|
+
<Badge
|
|
169
|
+
variant="softAmber"
|
|
170
|
+
className="ms-auto shrink-0"
|
|
171
|
+
data-testid={`email-automation-candidate-reason-${candidate.recipientId}`}
|
|
172
|
+
>
|
|
173
|
+
{t(`emailAutomations.recipients.addDialog.reason.${candidate.reason}`)}
|
|
174
|
+
</Badge>
|
|
175
|
+
)
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
</CommandItem>
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className="flex items-center gap-2">
|
|
183
|
+
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
184
|
+
<DialogTrigger
|
|
185
|
+
render={<Button variant="outline" size="sm" data-testid="email-automation-add-person" />}
|
|
186
|
+
disabled={disabled}
|
|
187
|
+
>
|
|
188
|
+
<UserPlusIcon className="h-3.5 w-3.5" />
|
|
189
|
+
{t("emailAutomations.recipients.addPerson")}
|
|
190
|
+
</DialogTrigger>
|
|
191
|
+
<DialogContent className="sm:max-w-2xl" data-testid="email-automation-add-person-dialog">
|
|
192
|
+
<DialogHeader>
|
|
193
|
+
<DialogTitle>{t("emailAutomations.recipients.addDialog.title")}</DialogTitle>
|
|
194
|
+
</DialogHeader>
|
|
195
|
+
<Command shouldFilter={false}>
|
|
196
|
+
<CommandInput
|
|
197
|
+
data-testid="email-automation-add-person-search"
|
|
198
|
+
placeholder={t("emailAutomations.recipients.addDialog.search")}
|
|
199
|
+
value={searchTerm}
|
|
200
|
+
onValueChange={setSearchTerm}
|
|
201
|
+
/>
|
|
202
|
+
<CommandList className="mt-2">
|
|
203
|
+
<CommandEmpty>{t("emailAutomations.recipients.addDialog.empty")}</CommandEmpty>
|
|
204
|
+
{addable.length > 0 && (
|
|
205
|
+
<CommandGroup heading={t("emailAutomations.recipients.addDialog.groups.addable")}>
|
|
206
|
+
{addable.map(renderCandidate)}
|
|
207
|
+
</CommandGroup>
|
|
208
|
+
)}
|
|
209
|
+
{blocked.length > 0 && (
|
|
210
|
+
<CommandGroup heading={t("emailAutomations.recipients.addDialog.groups.blocked")}>
|
|
211
|
+
{blocked.map(renderCandidate)}
|
|
212
|
+
</CommandGroup>
|
|
213
|
+
)}
|
|
214
|
+
{data.next && <div ref={sentinelRef} style={{ height: 1 }} />}
|
|
215
|
+
</CommandList>
|
|
216
|
+
</Command>
|
|
217
|
+
</DialogContent>
|
|
218
|
+
</Dialog>
|
|
219
|
+
{disabled && (
|
|
220
|
+
<span className="text-muted-foreground text-xs font-normal">
|
|
221
|
+
{t("emailAutomations.recipients.addPersonNoSteps")}
|
|
222
|
+
</span>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|