@carlonicora/nextjs-jsonapi 5.1.3 → 5.3.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-KO6YFNIU.js → BlockNoteEditor-UJ6363LC.js} +19 -19
- package/dist/{BlockNoteEditor-KO6YFNIU.js.map → BlockNoteEditor-UJ6363LC.js.map} +1 -1
- package/dist/{BlockNoteEditor-66FJUDL4.mjs → BlockNoteEditor-WTSQANKI.mjs} +4 -4
- package/dist/{BlockNoteViewer-LK22AE7Z.js → BlockNoteViewer-EL4JFB6P.js} +3 -3
- package/dist/{BlockNoteViewer-LK22AE7Z.js.map → BlockNoteViewer-EL4JFB6P.js.map} +1 -1
- package/dist/{BlockNoteViewer-SUK7OMWH.mjs → BlockNoteViewer-JVKWJZ6J.mjs} +2 -2
- package/dist/auth.interface-CSzJ1oP1.d.ts +64 -0
- package/dist/auth.interface-DD4uQv2K.d.mts +64 -0
- package/dist/billing/index.js +441 -441
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-SZQNUGDG.js → chunk-3BAM23VA.js} +1005 -929
- package/dist/chunk-3BAM23VA.js.map +1 -0
- package/dist/{chunk-OKUGTXDN.mjs → chunk-6WU74VFU.mjs} +121 -3
- package/dist/chunk-6WU74VFU.mjs.map +1 -0
- package/dist/{chunk-VVJDWQEI.js → chunk-CRI4T47Y.js} +790 -179
- package/dist/chunk-CRI4T47Y.js.map +1 -0
- package/dist/{chunk-TO6IYGML.js → chunk-EO75CV76.js} +126 -8
- package/dist/chunk-EO75CV76.js.map +1 -0
- package/dist/{chunk-KL7CAQP3.mjs → chunk-FNTQQFLH.mjs} +2 -2
- package/dist/{chunk-OCJEXAGE.mjs → chunk-JQWI62NV.mjs} +617 -6
- package/dist/chunk-JQWI62NV.mjs.map +1 -0
- package/dist/{chunk-5JADF7ZA.mjs → chunk-MIZTWBPX.mjs} +171 -95
- package/dist/chunk-MIZTWBPX.mjs.map +1 -0
- package/dist/{chunk-XLVRAHUK.mjs → chunk-QKZUE5OF.mjs} +2 -2
- package/dist/{chunk-67NJY7SM.js → chunk-QQQE3N2X.js} +3 -3
- package/dist/{chunk-67NJY7SM.js.map → chunk-QQQE3N2X.js.map} +1 -1
- package/dist/{chunk-XCHXOCEJ.js → chunk-XGTQWBDJ.js} +4 -4
- package/dist/{chunk-XCHXOCEJ.js.map → chunk-XGTQWBDJ.js.map} +1 -1
- package/dist/client/index.d.mts +6 -39
- package/dist/client/index.d.ts +6 -39
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +19 -5
- package/dist/components/index.d.ts +19 -5
- package/dist/components/index.js +6 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +5 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +214 -8
- package/dist/core/index.d.ts +214 -8
- package/dist/core/index.js +30 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +29 -1
- package/dist/email-automation-enrollment.fields-BZvF14qx.d.mts +82 -0
- package/dist/email-automation-enrollment.fields-Cp3VMzFq.d.ts +82 -0
- package/dist/email-automations/index.css +30 -0
- package/dist/email-automations/index.css.map +1 -0
- package/dist/email-automations/index.d.mts +303 -0
- package/dist/email-automations/index.d.ts +303 -0
- package/dist/email-automations/index.js +1705 -0
- package/dist/email-automations/index.js.map +1 -0
- package/dist/email-automations/index.mjs +1705 -0
- package/dist/email-automations/index.mjs.map +1 -0
- package/dist/features/analytics/index.js +122 -122
- package/dist/features/analytics/index.mjs +4 -4
- package/dist/features/analytics/server.js +4 -4
- package/dist/features/analytics/server.mjs +2 -2
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +125 -125
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +5 -4
- package/dist/index.d.ts +5 -4
- package/dist/index.js +41 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +41 -3
- package/dist/{s3.service-C-4YpEMu.d.ts → s3.service-CY-jgOAM.d.ts} +1 -1
- package/dist/{s3.service-dVj-nxMl.d.mts → s3.service-Sri0QP7Z.d.mts} +1 -1
- package/dist/server/index.d.mts +2 -2
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/dist/types-70acd-8C.d.mts +39 -0
- package/dist/types-l8SFleXi.d.ts +39 -0
- package/dist/{waitlist.config-kPfjImle.d.mts → waitlist.config-Cagmgm7-.d.mts} +7 -0
- package/dist/{waitlist.config-kPfjImle.d.ts → waitlist.config-Cagmgm7-.d.ts} +7 -0
- package/messages/en.json +132 -1
- package/messages/it.json +132 -1
- package/package.json +11 -1
- package/scripts/validate-messages.mjs +1 -0
- package/src/components/forms/FormFieldWrapper.tsx +1 -1
- package/src/components/forms/FormTextarea.tsx +2 -1
- package/src/components/index.ts +1 -0
- package/src/core/index.ts +8 -0
- package/src/core/registry/ModuleRegistry.ts +5 -0
- package/src/email-automations/index.ts +5 -0
- package/src/features/auth/components/buttons/GoogleSignInButton.tsx +8 -4
- package/src/features/auth/components/forms/Register.tsx +20 -8
- package/src/features/auth/data/auth.interface.ts +2 -0
- package/src/features/auth/data/auth.ts +10 -0
- package/src/features/email-automation/EmailAutomationEnrollmentModule.ts +11 -0
- package/src/features/email-automation/EmailAutomationModule.ts +30 -0
- package/src/features/email-automation/EmailAutomationStepModule.ts +11 -0
- package/src/features/email-automation/EmailAutomationTriggerModule.ts +11 -0
- package/src/features/email-automation/components/__tests__/EmailAutomationEditor.spec.tsx +104 -0
- package/src/features/email-automation/components/canvas/EmailAutomationActiveSwitch.tsx +49 -0
- package/src/features/email-automation/components/canvas/EmailAutomationCanvas.tsx +474 -0
- package/src/features/email-automation/components/canvas/EmailAutomationCanvasInspector.tsx +89 -0
- package/src/features/email-automation/components/canvas/EmailAutomationCanvasTab.tsx +32 -0
- package/src/features/email-automation/components/canvas/__tests__/buildCanvasGraph.spec.ts +67 -0
- package/src/features/email-automation/components/canvas/buildCanvasGraph.ts +141 -0
- package/src/features/email-automation/components/canvas/edges/DelayEdge.tsx +68 -0
- package/src/features/email-automation/components/canvas/nodes/EmailStepNode.tsx +43 -0
- package/src/features/email-automation/components/canvas/nodes/TriggerNode.tsx +23 -0
- package/src/features/email-automation/components/canvas/shared/CanvasToolbar.tsx +53 -0
- package/src/features/email-automation/components/canvas/shared/EmptyCanvas.tsx +11 -0
- package/src/features/email-automation/components/canvas/shared/__tests__/autoLayout.spec.ts +42 -0
- package/src/features/email-automation/components/canvas/shared/__tests__/longPress.spec.ts +51 -0
- package/src/features/email-automation/components/canvas/shared/autoLayout.ts +55 -0
- package/src/features/email-automation/components/canvas/shared/index.ts +4 -0
- package/src/features/email-automation/components/canvas/shared/longPress.ts +57 -0
- package/src/features/email-automation/components/containers/EmailAutomationContainer.tsx +80 -0
- package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx +27 -0
- package/src/features/email-automation/components/forms/EmailAutomationEditor.tsx +153 -0
- package/src/features/email-automation/components/forms/EmailStepEditor.tsx +151 -0
- package/src/features/email-automation/components/index.ts +15 -0
- package/src/features/email-automation/components/lists/EmailAutomationRecipientsList.tsx +90 -0
- package/src/features/email-automation/components/lists/EmailAutomationsList.tsx +76 -0
- package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +99 -0
- package/src/features/email-automation/components/unsubscribe/__tests__/EmailUnsubscribe.spec.tsx +55 -0
- package/src/features/email-automation/contexts/EmailAutomationContext.tsx +175 -0
- package/src/features/email-automation/data/EmailAutomation.ts +87 -0
- package/src/features/email-automation/data/EmailAutomationEnrollment.ts +105 -0
- package/src/features/email-automation/data/EmailAutomationEnrollmentInterface.ts +18 -0
- package/src/features/email-automation/data/EmailAutomationEnrollmentService.ts +31 -0
- package/src/features/email-automation/data/EmailAutomationInterface.ts +22 -0
- package/src/features/email-automation/data/EmailAutomationService.ts +106 -0
- package/src/features/email-automation/data/EmailAutomationStep.ts +104 -0
- package/src/features/email-automation/data/EmailAutomationStepInterface.ts +23 -0
- package/src/features/email-automation/data/EmailAutomationStepService.ts +80 -0
- package/src/features/email-automation/data/EmailAutomationTrigger.ts +28 -0
- package/src/features/email-automation/data/EmailAutomationTriggerInterface.ts +6 -0
- package/src/features/email-automation/data/__tests__/email-automation-models.spec.ts +103 -0
- package/src/features/email-automation/data/email-automation-enrollment.fields.ts +8 -0
- package/src/features/email-automation/data/email-automation.fields.ts +9 -0
- package/src/features/email-automation/data/index.ts +13 -0
- package/src/features/email-automation/hooks/index.ts +2 -0
- package/src/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.tsx +126 -0
- package/src/features/email-automation/hooks/useEmailAutomationTableStructure.tsx +119 -0
- package/src/features/email-automation/index.ts +5 -0
- package/src/features/signup-source/SignupSourceCapture.tsx +27 -0
- package/src/features/signup-source/__tests__/signup-source.spec.ts +161 -0
- package/src/features/signup-source/index.ts +9 -0
- package/src/features/signup-source/signup-source.ts +135 -0
- package/src/features/waitlist/components/forms/WaitlistForm.tsx +2 -0
- package/src/features/waitlist/components/forms/WaitlistQuestionnaireRenderer.tsx +15 -13
- package/src/features/waitlist/components/lists/WaitlistList.tsx +60 -22
- package/src/features/waitlist/components/sections/WaitlistHeroSection.tsx +6 -5
- package/src/features/waitlist/config/waitlist.config.ts +23 -0
- package/src/features/waitlist/data/Waitlist.ts +51 -3
- package/src/features/waitlist/data/WaitlistInterface.ts +9 -1
- package/src/features/waitlist/data/WaitlistService.ts +3 -2
- package/src/features/waitlist/data/__tests__/Waitlist.spec.ts +82 -0
- package/src/features/waitlist/data/__tests__/WaitlistService.spec.ts +48 -0
- package/src/features/waitlist/data/waitlist.fields.ts +1 -0
- package/src/features/waitlist/hooks/__tests__/formatSignupSource.spec.ts +24 -0
- package/src/features/waitlist/hooks/formatSignupSource.ts +14 -0
- package/src/features/waitlist/hooks/useWaitlistTableStructure.tsx +26 -14
- package/src/hooks/__tests__/usePageTracker.spec.tsx +16 -10
- package/src/index.ts +3 -0
- package/dist/auth.interface-CeFCz9NU.d.mts +0 -34
- package/dist/auth.interface-D1R7VVky.d.ts +0 -34
- package/dist/chunk-5JADF7ZA.mjs.map +0 -1
- package/dist/chunk-OCJEXAGE.mjs.map +0 -1
- package/dist/chunk-OKUGTXDN.mjs.map +0 -1
- package/dist/chunk-SZQNUGDG.js.map +0 -1
- package/dist/chunk-TO6IYGML.js.map +0 -1
- package/dist/chunk-VVJDWQEI.js.map +0 -1
- /package/dist/{BlockNoteEditor-66FJUDL4.mjs.map → BlockNoteEditor-WTSQANKI.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-SUK7OMWH.mjs.map → BlockNoteViewer-JVKWJZ6J.mjs.map} +0 -0
- /package/dist/{chunk-KL7CAQP3.mjs.map → chunk-FNTQQFLH.mjs.map} +0 -0
- /package/dist/{chunk-XLVRAHUK.mjs.map → chunk-QKZUE5OF.mjs.map} +0 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { MailIcon } from "lucide-react";
|
|
2
|
+
import { ModuleFactory } from "../../permissions";
|
|
3
|
+
import { EmailAutomationStep } from "./data/EmailAutomationStep";
|
|
4
|
+
|
|
5
|
+
export const EmailAutomationStepModule = (factory: ModuleFactory) =>
|
|
6
|
+
factory({
|
|
7
|
+
pageUrl: undefined,
|
|
8
|
+
name: "email-automation-steps",
|
|
9
|
+
model: EmailAutomationStep,
|
|
10
|
+
icon: MailIcon,
|
|
11
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ZapIcon } from "lucide-react";
|
|
2
|
+
import { ModuleFactory } from "../../permissions";
|
|
3
|
+
import { EmailAutomationTrigger } from "./data/EmailAutomationTrigger";
|
|
4
|
+
|
|
5
|
+
export const EmailAutomationTriggerModule = (factory: ModuleFactory) =>
|
|
6
|
+
factory({
|
|
7
|
+
pageUrl: undefined,
|
|
8
|
+
name: "email-automation-triggers",
|
|
9
|
+
model: EmailAutomationTrigger,
|
|
10
|
+
icon: ZapIcon,
|
|
11
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
2
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
|
|
4
|
+
import { configureI18n } from "../../../../i18n";
|
|
5
|
+
import { EmailAutomationEditor } from "../forms/EmailAutomationEditor";
|
|
6
|
+
|
|
7
|
+
// Hoisted so the vi.mock factories below (which run before the module body)
|
|
8
|
+
// can close over them.
|
|
9
|
+
const mocks = vi.hoisted(() => ({
|
|
10
|
+
findTriggers: vi.fn(),
|
|
11
|
+
create: vi.fn(),
|
|
12
|
+
update: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("../../data/EmailAutomationService", () => ({
|
|
16
|
+
EmailAutomationService: {
|
|
17
|
+
findTriggers: mocks.findTriggers,
|
|
18
|
+
create: mocks.create,
|
|
19
|
+
update: mocks.update,
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Base UI renders a closed Select's options nowhere in the DOM, so FormSelect
|
|
24
|
+
// is replaced by a stub that prints its label, its empty option and its
|
|
25
|
+
// values. Everything else in the barrel stays real.
|
|
26
|
+
vi.mock("../../../../components", async () => {
|
|
27
|
+
const actual = await vi.importActual<typeof import("../../../../components")>("../../../../components");
|
|
28
|
+
return {
|
|
29
|
+
...actual,
|
|
30
|
+
FormSelect: (props: {
|
|
31
|
+
id: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
allowEmpty?: boolean;
|
|
35
|
+
values: { id: string; text: string }[];
|
|
36
|
+
}) => (
|
|
37
|
+
<div data-testid={`form-select-${props.id}`}>
|
|
38
|
+
<span>{props.name}</span>
|
|
39
|
+
{props.allowEmpty && <span data-value="">{props.placeholder}</span>}
|
|
40
|
+
{props.values.map((value) => (
|
|
41
|
+
<span key={value.id} data-value={value.id}>
|
|
42
|
+
{value.text}
|
|
43
|
+
</span>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const TRIGGERS = [
|
|
51
|
+
{ id: "waitlist-confirmed", key: "waitlist-confirmed", label: "Waitlist confirmed" },
|
|
52
|
+
{ id: "registration-completed", key: "registration-completed", label: "Registration completed" },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// `Modules.EmailAutomation` resolves through the registry at render time.
|
|
56
|
+
beforeAll(() => {
|
|
57
|
+
ModuleRegistry.register("EmailAutomation", {
|
|
58
|
+
name: "email-automations",
|
|
59
|
+
pageUrl: "/administration/email-automations",
|
|
60
|
+
} as any);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
vi.clearAllMocks();
|
|
65
|
+
mocks.findTriggers.mockResolvedValue(TRIGGERS);
|
|
66
|
+
configureI18n({
|
|
67
|
+
useRouter: () => ({
|
|
68
|
+
push: vi.fn(),
|
|
69
|
+
replace: vi.fn(),
|
|
70
|
+
back: vi.fn(),
|
|
71
|
+
forward: vi.fn(),
|
|
72
|
+
refresh: vi.fn(),
|
|
73
|
+
prefetch: vi.fn(),
|
|
74
|
+
}),
|
|
75
|
+
useTranslations: () => (key: string) => key,
|
|
76
|
+
Link: ({ children }: any) => children,
|
|
77
|
+
usePathname: () => "/",
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("EmailAutomationEditor", () => {
|
|
82
|
+
it("renders the five fields by their labels", async () => {
|
|
83
|
+
render(<EmailAutomationEditor forceShow dialogOpen />);
|
|
84
|
+
|
|
85
|
+
expect(await screen.findByText("emailAutomations.fields.name.label")).toBeInTheDocument();
|
|
86
|
+
expect(screen.getByText("emailAutomations.fields.trigger.label")).toBeInTheDocument();
|
|
87
|
+
expect(screen.getByText("emailAutomations.fields.exitTrigger.label")).toBeInTheDocument();
|
|
88
|
+
expect(screen.getByText("emailAutomations.fields.replyTo.label")).toBeInTheDocument();
|
|
89
|
+
expect(screen.getByText("emailAutomations.fields.isActive.label")).toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("lists the none option plus the trigger labels returned by the API", async () => {
|
|
93
|
+
render(<EmailAutomationEditor forceShow dialogOpen />);
|
|
94
|
+
|
|
95
|
+
const triggerSelect = await screen.findByTestId("form-select-trigger");
|
|
96
|
+
await waitFor(() => expect(within(triggerSelect).getByText("Waitlist confirmed")).toBeInTheDocument());
|
|
97
|
+
|
|
98
|
+
expect(mocks.findTriggers).toHaveBeenCalled();
|
|
99
|
+
expect(within(triggerSelect).getByText("emailAutomations.fields.trigger.none")).toBeInTheDocument();
|
|
100
|
+
expect(within(triggerSelect).getByText("Registration completed")).toBeInTheDocument();
|
|
101
|
+
// The option values are the trigger keys, the visible text the plain label.
|
|
102
|
+
expect(within(triggerSelect).getByText("Waitlist confirmed")).toHaveAttribute("data-value", "waitlist-confirmed");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { errorToast } from "../../../../components";
|
|
6
|
+
import { Switch, Tooltip, TooltipContent, TooltipTrigger } from "../../../../shadcnui";
|
|
7
|
+
import { useEmailAutomation } from "../../contexts/EmailAutomationContext";
|
|
8
|
+
import { EmailAutomationService } from "../../data/EmailAutomationService";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Inline on/off for the automation page header, so pausing never needs the
|
|
12
|
+
* editor sheet. Off holds every enrolment in place (spec § "Sending" 3).
|
|
13
|
+
*/
|
|
14
|
+
export function EmailAutomationActiveSwitch() {
|
|
15
|
+
const t = useTranslations();
|
|
16
|
+
const { automation, reload } = useEmailAutomation();
|
|
17
|
+
const [saving, setSaving] = useState<boolean>(false);
|
|
18
|
+
|
|
19
|
+
if (!automation) return null;
|
|
20
|
+
|
|
21
|
+
const toggle = async (checked: boolean) => {
|
|
22
|
+
setSaving(true);
|
|
23
|
+
try {
|
|
24
|
+
await EmailAutomationService.update({
|
|
25
|
+
id: automation.id,
|
|
26
|
+
name: automation.name,
|
|
27
|
+
trigger: automation.trigger,
|
|
28
|
+
exitTrigger: automation.exitTrigger,
|
|
29
|
+
replyTo: automation.replyTo,
|
|
30
|
+
isActive: checked,
|
|
31
|
+
});
|
|
32
|
+
await reload();
|
|
33
|
+
} catch (error) {
|
|
34
|
+
errorToast({ title: t("common.errors.error"), error });
|
|
35
|
+
} finally {
|
|
36
|
+
setSaving(false);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Tooltip>
|
|
42
|
+
<TooltipTrigger render={<label className="flex items-center gap-x-2" />}>
|
|
43
|
+
<Switch checked={automation.isActive} onCheckedChange={(checked) => void toggle(checked)} disabled={saving} />
|
|
44
|
+
<span className="text-xs">{t("emailAutomations.fields.isActive.label")}</span>
|
|
45
|
+
</TooltipTrigger>
|
|
46
|
+
<TooltipContent className="max-w-xs">{t("emailAutomations.fields.isActive.help")}</TooltipContent>
|
|
47
|
+
</Tooltip>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Background,
|
|
5
|
+
Connection,
|
|
6
|
+
Edge,
|
|
7
|
+
EdgeChange,
|
|
8
|
+
FinalConnectionState,
|
|
9
|
+
Node,
|
|
10
|
+
NodeChange,
|
|
11
|
+
OnConnectStart,
|
|
12
|
+
OnNodeDrag,
|
|
13
|
+
ReactFlow,
|
|
14
|
+
applyEdgeChanges,
|
|
15
|
+
applyNodeChanges,
|
|
16
|
+
useReactFlow,
|
|
17
|
+
} from "@xyflow/react";
|
|
18
|
+
import "@xyflow/react/dist/style.css";
|
|
19
|
+
import { PlusIcon, Trash2Icon } from "lucide-react";
|
|
20
|
+
import { useTranslations } from "next-intl";
|
|
21
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
22
|
+
import { v4 as uuidv4 } from "uuid";
|
|
23
|
+
import { errorToast } from "../../../../components/errors";
|
|
24
|
+
import { ConfirmDialog, ContextMenu, ContextMenuContent, ContextMenuItem } from "../../../../shadcnui";
|
|
25
|
+
import { useIsMobile } from "../../../../utils/use-mobile";
|
|
26
|
+
import { showError } from "../../../../utils/toast";
|
|
27
|
+
import { useEmailAutomation } from "../../contexts/EmailAutomationContext";
|
|
28
|
+
import { EmailAutomationEditor } from "../forms/EmailAutomationEditor";
|
|
29
|
+
import {
|
|
30
|
+
STEP_NODE_HEIGHT,
|
|
31
|
+
STEP_NODE_PREFIX,
|
|
32
|
+
STEP_NODE_WIDTH,
|
|
33
|
+
STEP_SOURCE_HANDLE_PREFIX,
|
|
34
|
+
STEP_TARGET_HANDLE_PREFIX,
|
|
35
|
+
TRIGGER_NODE_ID,
|
|
36
|
+
TRIGGER_SOURCE_HANDLE,
|
|
37
|
+
buildCanvasGraph,
|
|
38
|
+
} from "./buildCanvasGraph";
|
|
39
|
+
import { DelayEdge } from "./edges/DelayEdge";
|
|
40
|
+
import { EmailAutomationCanvasInspector, useUserClosedGuard } from "./EmailAutomationCanvasInspector";
|
|
41
|
+
import { EmailStepNode } from "./nodes/EmailStepNode";
|
|
42
|
+
import { TriggerNode } from "./nodes/TriggerNode";
|
|
43
|
+
import { CanvasToolbar, EmptyCanvas, createLongPressTracker } from "./shared";
|
|
44
|
+
|
|
45
|
+
const DEFAULT_DELAY_HOURS = 24;
|
|
46
|
+
|
|
47
|
+
type PendingPaneMenu = {
|
|
48
|
+
flowX: number;
|
|
49
|
+
flowY: number;
|
|
50
|
+
screenX: number;
|
|
51
|
+
screenY: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type PendingNodeMenu = {
|
|
55
|
+
stepId: string;
|
|
56
|
+
screenX: number;
|
|
57
|
+
screenY: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const pointAnchor = (screenX: number, screenY: number) => ({
|
|
61
|
+
getBoundingClientRect: () =>
|
|
62
|
+
({
|
|
63
|
+
x: screenX,
|
|
64
|
+
y: screenY,
|
|
65
|
+
top: screenY,
|
|
66
|
+
left: screenX,
|
|
67
|
+
right: screenX,
|
|
68
|
+
bottom: screenY,
|
|
69
|
+
width: 0,
|
|
70
|
+
height: 0,
|
|
71
|
+
toJSON: () => ({}),
|
|
72
|
+
}) as DOMRect,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const stepIdOf = (nodeId: string | null | undefined): string | null =>
|
|
76
|
+
nodeId?.startsWith(STEP_NODE_PREFIX) ? nodeId.slice(STEP_NODE_PREFIX.length) : null;
|
|
77
|
+
|
|
78
|
+
export function EmailAutomationCanvas() {
|
|
79
|
+
const { automation, steps, triggers, select, reload, createStep, deleteStep, setNext, updatePosition, tidy } =
|
|
80
|
+
useEmailAutomation();
|
|
81
|
+
const t = useTranslations();
|
|
82
|
+
// Phone-only: a looser zoom-out floor so "Fit to view" can show the whole
|
|
83
|
+
// graph. Desktop keeps React Flow's defaults untouched.
|
|
84
|
+
const isMobile = useIsMobile();
|
|
85
|
+
|
|
86
|
+
const [nodes, setNodes] = useState<Node[]>([]);
|
|
87
|
+
const [edges, setEdges] = useState<Edge[]>([]);
|
|
88
|
+
const { screenToFlowPosition } = useReactFlow();
|
|
89
|
+
const connectingFromRef = useRef<{ nodeId: string | null; handleId: string | null } | null>(null);
|
|
90
|
+
const [paneMenu, setPaneMenu] = useState<PendingPaneMenu | null>(null);
|
|
91
|
+
const [nodeMenu, setNodeMenu] = useState<PendingNodeMenu | null>(null);
|
|
92
|
+
const [pendingDeleteStepId, setPendingDeleteStepId] = useState<string | null>(null);
|
|
93
|
+
const [automationEditorOpen, setAutomationEditorOpen] = useState<boolean>(false);
|
|
94
|
+
|
|
95
|
+
const triggerLabel = useMemo(
|
|
96
|
+
() =>
|
|
97
|
+
triggers.find((trigger) => trigger.key === automation?.trigger)?.label ?? t("emailAutomations.canvas.noTrigger"),
|
|
98
|
+
[triggers, automation?.trigger, t],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const { laidNodes, laidEdges } = useMemo(() => {
|
|
102
|
+
const graph = buildCanvasGraph(steps, triggerLabel);
|
|
103
|
+
return { laidNodes: graph.nodes, laidEdges: graph.edges };
|
|
104
|
+
}, [steps, triggerLabel]);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
setNodes(laidNodes);
|
|
108
|
+
setEdges(laidEdges);
|
|
109
|
+
}, [laidNodes, laidEdges]);
|
|
110
|
+
|
|
111
|
+
const onNodesChange = useCallback((changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)), []);
|
|
112
|
+
const onEdgesChange = useCallback((changes: EdgeChange[]) => setEdges((eds) => applyEdgeChanges(changes, eds)), []);
|
|
113
|
+
|
|
114
|
+
// The trigger has no stored position: only steps are persisted.
|
|
115
|
+
const onNodeDragStop: OnNodeDrag = useCallback(
|
|
116
|
+
(_e, node) => {
|
|
117
|
+
const stepId = stepIdOf(node.id);
|
|
118
|
+
if (!stepId) return;
|
|
119
|
+
void updatePosition(stepId, node.position.x, node.position.y);
|
|
120
|
+
},
|
|
121
|
+
[updatePosition],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const onConnectStart: OnConnectStart = useCallback((_e, params) => {
|
|
125
|
+
connectingFromRef.current = { nodeId: params.nodeId ?? null, handleId: params.handleId ?? null };
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
/** A rejected link (self-link, loop, step already linked) reloads the true chain. */
|
|
129
|
+
const linkSteps = useCallback(
|
|
130
|
+
async (stepId: string, nextStepId: string) => {
|
|
131
|
+
try {
|
|
132
|
+
await setNext(stepId, nextStepId);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
errorToast({ title: t("emailAutomations.canvas.chainError"), error });
|
|
135
|
+
await reload();
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
[setNext, reload, t],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// onConnect fires only when React Flow finds a valid target handle.
|
|
142
|
+
const onConnect = useCallback(
|
|
143
|
+
async (conn: Connection) => {
|
|
144
|
+
const from = connectingFromRef.current;
|
|
145
|
+
connectingFromRef.current = null;
|
|
146
|
+
if (!from || !from.handleId) return;
|
|
147
|
+
if (!conn.targetHandle?.startsWith(STEP_TARGET_HANDLE_PREFIX)) return;
|
|
148
|
+
const targetStepId = conn.targetHandle.slice(STEP_TARGET_HANDLE_PREFIX.length);
|
|
149
|
+
|
|
150
|
+
// The trigger→head link is not stored: the API derives the head as the
|
|
151
|
+
// oldest step with no incoming NEXT. A drag from the trigger onto a step
|
|
152
|
+
// therefore cannot be saved, so it is refused with the chain error and
|
|
153
|
+
// nothing changes.
|
|
154
|
+
if (from.handleId === TRIGGER_SOURCE_HANDLE) {
|
|
155
|
+
showError(t("emailAutomations.canvas.chainError"));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!from.handleId.startsWith(STEP_SOURCE_HANDLE_PREFIX)) return;
|
|
160
|
+
const sourceStepId = from.handleId.slice(STEP_SOURCE_HANDLE_PREFIX.length);
|
|
161
|
+
await linkSteps(sourceStepId, targetStepId);
|
|
162
|
+
},
|
|
163
|
+
[linkSteps, t],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
/** Creates a step centred on a flow point and returns its id. */
|
|
167
|
+
const createStepAt = useCallback(
|
|
168
|
+
async (flowX: number, flowY: number): Promise<string | null> => {
|
|
169
|
+
if (!automation) return null;
|
|
170
|
+
const id = uuidv4();
|
|
171
|
+
await createStep({
|
|
172
|
+
id,
|
|
173
|
+
automationId: automation.id,
|
|
174
|
+
subject: t("emailAutomations.step.title"),
|
|
175
|
+
body: "",
|
|
176
|
+
delayHours: DEFAULT_DELAY_HOURS,
|
|
177
|
+
positionX: flowX - STEP_NODE_WIDTH / 2,
|
|
178
|
+
positionY: flowY - STEP_NODE_HEIGHT / 2,
|
|
179
|
+
});
|
|
180
|
+
return id;
|
|
181
|
+
},
|
|
182
|
+
[automation, createStep, t],
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// onConnectEnd fires for EVERY drag end. If onConnect already cleared the
|
|
186
|
+
// ref, it handled the case. Otherwise a drop on empty canvas creates a new
|
|
187
|
+
// step there: from a step it is linked as that step's next; from the
|
|
188
|
+
// trigger it is simply the first step when none existed, otherwise an
|
|
189
|
+
// orphan (the trigger→head link is derived, never stored).
|
|
190
|
+
const onConnectEnd = useCallback(
|
|
191
|
+
async (event: MouseEvent | TouchEvent, connectionState: FinalConnectionState) => {
|
|
192
|
+
const from = connectingFromRef.current;
|
|
193
|
+
if (!from || !from.handleId || !automation) return;
|
|
194
|
+
connectingFromRef.current = null;
|
|
195
|
+
// Dropped on a node but not on its handle: not an empty-canvas drop.
|
|
196
|
+
if (connectionState.toNode) return;
|
|
197
|
+
|
|
198
|
+
const clientX =
|
|
199
|
+
"clientX" in event ? (event as MouseEvent).clientX : ((event as TouchEvent).changedTouches[0]?.clientX ?? 0);
|
|
200
|
+
const clientY =
|
|
201
|
+
"clientY" in event ? (event as MouseEvent).clientY : ((event as TouchEvent).changedTouches[0]?.clientY ?? 0);
|
|
202
|
+
const flowPos = screenToFlowPosition({ x: clientX, y: clientY });
|
|
203
|
+
|
|
204
|
+
const sourceStepId = from.handleId.startsWith(STEP_SOURCE_HANDLE_PREFIX)
|
|
205
|
+
? from.handleId.slice(STEP_SOURCE_HANDLE_PREFIX.length)
|
|
206
|
+
: null;
|
|
207
|
+
if (!sourceStepId && from.handleId !== TRIGGER_SOURCE_HANDLE) return;
|
|
208
|
+
|
|
209
|
+
const newStepId = await createStepAt(flowPos.x, flowPos.y);
|
|
210
|
+
if (!newStepId) return;
|
|
211
|
+
if (sourceStepId) await linkSteps(sourceStepId, newStepId);
|
|
212
|
+
select(newStepId);
|
|
213
|
+
},
|
|
214
|
+
[automation, screenToFlowPosition, createStepAt, linkSteps, select],
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// --- Pane context menu ---
|
|
218
|
+
|
|
219
|
+
const handlePaneContextMenu = useCallback(
|
|
220
|
+
(event: React.MouseEvent | MouseEvent) => {
|
|
221
|
+
if (!automation) return;
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
const clientX = (event as MouseEvent).clientX;
|
|
224
|
+
const clientY = (event as MouseEvent).clientY;
|
|
225
|
+
const flowPos = screenToFlowPosition({ x: clientX, y: clientY });
|
|
226
|
+
setPaneMenu({
|
|
227
|
+
flowX: flowPos.x,
|
|
228
|
+
flowY: flowPos.y,
|
|
229
|
+
screenX: clientX,
|
|
230
|
+
screenY: clientY,
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
[automation, screenToFlowPosition],
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const handleCreateStepFromPane = useCallback(async () => {
|
|
237
|
+
const menu = paneMenu;
|
|
238
|
+
if (!menu) return;
|
|
239
|
+
setPaneMenu(null);
|
|
240
|
+
const newStepId = await createStepAt(menu.flowX, menu.flowY);
|
|
241
|
+
if (newStepId) select(newStepId);
|
|
242
|
+
}, [paneMenu, createStepAt, select]);
|
|
243
|
+
|
|
244
|
+
// --- Node context menu ---
|
|
245
|
+
//
|
|
246
|
+
// Rendered through the same portal-based ContextMenu + fixed-anchor
|
|
247
|
+
// technique as the pane menu: Base UI menus do NOT work when mounted inside
|
|
248
|
+
// a React Flow node, so the menu lives outside the canvas and is anchored
|
|
249
|
+
// to the click coordinates. The trigger node has no menu.
|
|
250
|
+
const openNodeMenu = useCallback((nodeId: string | undefined, screenX: number, screenY: number) => {
|
|
251
|
+
const stepId = stepIdOf(nodeId);
|
|
252
|
+
if (!stepId) return;
|
|
253
|
+
setNodeMenu({ stepId, screenX, screenY });
|
|
254
|
+
}, []);
|
|
255
|
+
|
|
256
|
+
const handleNodeContextMenu = useCallback(
|
|
257
|
+
(event: React.MouseEvent, node: Node) => {
|
|
258
|
+
event.preventDefault();
|
|
259
|
+
openNodeMenu(node.id, event.clientX, event.clientY);
|
|
260
|
+
},
|
|
261
|
+
[openNodeMenu],
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// --- Touch: long-press a node opens the same node menu ---
|
|
265
|
+
//
|
|
266
|
+
// Touch screens have no right-click. A ~500ms finger hold on a step node
|
|
267
|
+
// opens the menu at the finger; moving the finger (pan / node drag) cancels
|
|
268
|
+
// it, and the click produced by lifting the finger is swallowed so the hold
|
|
269
|
+
// does not also open the step editor.
|
|
270
|
+
const openNodeMenuRef = useRef(openNodeMenu);
|
|
271
|
+
openNodeMenuRef.current = openNodeMenu;
|
|
272
|
+
const longPress = useMemo(
|
|
273
|
+
() =>
|
|
274
|
+
createLongPressTracker({
|
|
275
|
+
onLongPress: ({ target, clientX, clientY }) => {
|
|
276
|
+
openNodeMenuRef.current(target, clientX, clientY);
|
|
277
|
+
},
|
|
278
|
+
}),
|
|
279
|
+
[],
|
|
280
|
+
);
|
|
281
|
+
useEffect(() => () => longPress.cancel(), [longPress]);
|
|
282
|
+
|
|
283
|
+
const handleTouchPointerDown = useCallback(
|
|
284
|
+
(event: React.PointerEvent) => {
|
|
285
|
+
if (event.pointerType !== "touch") return;
|
|
286
|
+
const el = event.target as Element | null;
|
|
287
|
+
// A press on a connection handle starts a link drag, never a menu.
|
|
288
|
+
if (!el || el.closest(".react-flow__handle")) return;
|
|
289
|
+
const nodeId = el.closest(".react-flow__node")?.getAttribute("data-id");
|
|
290
|
+
if (!stepIdOf(nodeId)) return;
|
|
291
|
+
longPress.down(nodeId!, event.clientX, event.clientY);
|
|
292
|
+
},
|
|
293
|
+
[longPress],
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
const handleNodeClick = useCallback(
|
|
297
|
+
(_event: React.MouseEvent, node: Node) => {
|
|
298
|
+
if (node.id === TRIGGER_NODE_ID) {
|
|
299
|
+
setAutomationEditorOpen(true);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const stepId = stepIdOf(node.id);
|
|
303
|
+
if (stepId) select(stepId);
|
|
304
|
+
},
|
|
305
|
+
[select],
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const handleDeleteStep = useCallback(async () => {
|
|
309
|
+
const stepId = pendingDeleteStepId;
|
|
310
|
+
if (!stepId) return;
|
|
311
|
+
try {
|
|
312
|
+
await deleteStep(stepId);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
errorToast({ title: t("emailAutomations.canvas.deleteEmail"), error });
|
|
315
|
+
throw error; // keeps the dialog open — ConfirmDialog swallows rejections
|
|
316
|
+
}
|
|
317
|
+
}, [pendingDeleteStepId, deleteStep, t]);
|
|
318
|
+
|
|
319
|
+
const nodeTypes = useMemo(() => ({ triggerNode: TriggerNode, emailStepNode: EmailStepNode }), []);
|
|
320
|
+
const edgeTypes = useMemo(() => ({ delayEdge: DelayEdge }), []);
|
|
321
|
+
|
|
322
|
+
return (
|
|
323
|
+
<>
|
|
324
|
+
<ReactFlow
|
|
325
|
+
nodes={nodes}
|
|
326
|
+
edges={edges}
|
|
327
|
+
onNodesChange={onNodesChange}
|
|
328
|
+
onEdgesChange={onEdgesChange}
|
|
329
|
+
onNodeDragStop={onNodeDragStop}
|
|
330
|
+
onConnectStart={onConnectStart}
|
|
331
|
+
onConnect={onConnect}
|
|
332
|
+
onConnectEnd={onConnectEnd}
|
|
333
|
+
onPaneContextMenu={handlePaneContextMenu}
|
|
334
|
+
onNodeContextMenu={handleNodeContextMenu}
|
|
335
|
+
onNodeClick={handleNodeClick}
|
|
336
|
+
nodeTypes={nodeTypes}
|
|
337
|
+
edgeTypes={edgeTypes}
|
|
338
|
+
// Built-in React Flow click handler — independent of EdgeLabelRenderer
|
|
339
|
+
// pointer-events. The label button calls `select` too; either path
|
|
340
|
+
// produces the same selection state.
|
|
341
|
+
onEdgeClick={(_event, edge) => {
|
|
342
|
+
const targetStepId = (edge.data as { targetStepId?: string } | undefined)?.targetStepId;
|
|
343
|
+
if (targetStepId) select(targetStepId);
|
|
344
|
+
}}
|
|
345
|
+
fitView
|
|
346
|
+
// Never zoom past 100% on fit: with no emails the trigger is the only
|
|
347
|
+
// node, and the default ceiling (2x) blows it up over the empty state.
|
|
348
|
+
fitViewOptions={{ maxZoom: 1 }}
|
|
349
|
+
// React Flow's default floor is 0.5, which on a phone stops "Fit to
|
|
350
|
+
// view" before the graph fits. undefined keeps the desktop default.
|
|
351
|
+
minZoom={isMobile ? 0.15 : undefined}
|
|
352
|
+
onPointerDownCapture={handleTouchPointerDown}
|
|
353
|
+
onPointerMoveCapture={(event) => {
|
|
354
|
+
if (event.pointerType === "touch") longPress.move(event.clientX, event.clientY);
|
|
355
|
+
}}
|
|
356
|
+
onPointerUpCapture={() => longPress.up()}
|
|
357
|
+
onPointerCancelCapture={() => longPress.cancel()}
|
|
358
|
+
onClickCapture={(event) => {
|
|
359
|
+
if (!longPress.consumeClick()) return;
|
|
360
|
+
event.preventDefault();
|
|
361
|
+
event.stopPropagation();
|
|
362
|
+
}}
|
|
363
|
+
proOptions={{ hideAttribution: true }}
|
|
364
|
+
>
|
|
365
|
+
<Background gap={16} size={1} />
|
|
366
|
+
</ReactFlow>
|
|
367
|
+
{steps.length === 0 && (
|
|
368
|
+
// The canvas stays mounted under the empty state: the first email is
|
|
369
|
+
// added by right-clicking it or dragging from the trigger.
|
|
370
|
+
// Lower half only, so the text sits below the centred trigger node.
|
|
371
|
+
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2">
|
|
372
|
+
<EmptyCanvas>
|
|
373
|
+
<div className="flex flex-col gap-1">
|
|
374
|
+
<span className="font-medium">{t("emailAutomations.canvas.emptyTitle")}</span>
|
|
375
|
+
<span className="text-xs/relaxed">{t("emailAutomations.canvas.emptyBody")}</span>
|
|
376
|
+
</div>
|
|
377
|
+
</EmptyCanvas>
|
|
378
|
+
</div>
|
|
379
|
+
)}
|
|
380
|
+
<CanvasToolbar onTidy={tidy} />
|
|
381
|
+
<EmailAutomationCanvasInspector />
|
|
382
|
+
{automationEditorOpen && automation && (
|
|
383
|
+
<TriggerAutomationEditor
|
|
384
|
+
automation={automation}
|
|
385
|
+
onSaved={reload}
|
|
386
|
+
onClose={() => setAutomationEditorOpen(false)}
|
|
387
|
+
/>
|
|
388
|
+
)}
|
|
389
|
+
<ConfirmDialog
|
|
390
|
+
open={pendingDeleteStepId !== null}
|
|
391
|
+
onOpenChange={(open) => {
|
|
392
|
+
if (!open) setPendingDeleteStepId(null);
|
|
393
|
+
}}
|
|
394
|
+
title={t("emailAutomations.canvas.deleteEmail")}
|
|
395
|
+
description={t("emailAutomations.canvas.deleteEmailConfirm")}
|
|
396
|
+
confirmLabel={t("emailAutomations.canvas.deleteEmail")}
|
|
397
|
+
cancelLabel={t("ui.buttons.cancel")}
|
|
398
|
+
destructive
|
|
399
|
+
onConfirm={handleDeleteStep}
|
|
400
|
+
/>
|
|
401
|
+
{paneMenu && (
|
|
402
|
+
<ContextMenu
|
|
403
|
+
open={true}
|
|
404
|
+
onOpenChange={(open) => {
|
|
405
|
+
if (!open) setPaneMenu(null);
|
|
406
|
+
}}
|
|
407
|
+
>
|
|
408
|
+
<ContextMenuContent
|
|
409
|
+
anchor={pointAnchor(paneMenu.screenX, paneMenu.screenY)}
|
|
410
|
+
side="bottom"
|
|
411
|
+
align="start"
|
|
412
|
+
sideOffset={4}
|
|
413
|
+
>
|
|
414
|
+
<ContextMenuItem onClick={handleCreateStepFromPane}>
|
|
415
|
+
<PlusIcon />
|
|
416
|
+
{t("emailAutomations.canvas.addEmail")}
|
|
417
|
+
</ContextMenuItem>
|
|
418
|
+
</ContextMenuContent>
|
|
419
|
+
</ContextMenu>
|
|
420
|
+
)}
|
|
421
|
+
{nodeMenu && (
|
|
422
|
+
<ContextMenu
|
|
423
|
+
open={true}
|
|
424
|
+
onOpenChange={(open) => {
|
|
425
|
+
if (!open) setNodeMenu(null);
|
|
426
|
+
}}
|
|
427
|
+
>
|
|
428
|
+
<ContextMenuContent
|
|
429
|
+
anchor={pointAnchor(nodeMenu.screenX, nodeMenu.screenY)}
|
|
430
|
+
side="bottom"
|
|
431
|
+
align="start"
|
|
432
|
+
sideOffset={4}
|
|
433
|
+
>
|
|
434
|
+
<ContextMenuItem
|
|
435
|
+
onClick={() => {
|
|
436
|
+
const menu = nodeMenu;
|
|
437
|
+
setNodeMenu(null);
|
|
438
|
+
if (menu) setPendingDeleteStepId(menu.stepId);
|
|
439
|
+
}}
|
|
440
|
+
>
|
|
441
|
+
<Trash2Icon />
|
|
442
|
+
{t("emailAutomations.canvas.deleteEmail")}
|
|
443
|
+
</ContextMenuItem>
|
|
444
|
+
</ContextMenuContent>
|
|
445
|
+
</ContextMenu>
|
|
446
|
+
)}
|
|
447
|
+
</>
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The trigger node's click opens the automation's own EditorSheet, controlled
|
|
453
|
+
* the same way the inspector controls the step editor.
|
|
454
|
+
*/
|
|
455
|
+
function TriggerAutomationEditor(props: {
|
|
456
|
+
automation: NonNullable<ReturnType<typeof useEmailAutomation>["automation"]>;
|
|
457
|
+
onSaved: () => Promise<void> | void;
|
|
458
|
+
onClose: () => void;
|
|
459
|
+
}) {
|
|
460
|
+
const onDialogOpenChange = useUserClosedGuard(props.onClose);
|
|
461
|
+
|
|
462
|
+
return (
|
|
463
|
+
<EmailAutomationEditor
|
|
464
|
+
automation={props.automation}
|
|
465
|
+
forceShow
|
|
466
|
+
dialogOpen
|
|
467
|
+
onDialogOpenChange={onDialogOpenChange}
|
|
468
|
+
onSuccess={async () => {
|
|
469
|
+
await props.onSaved();
|
|
470
|
+
props.onClose();
|
|
471
|
+
}}
|
|
472
|
+
/>
|
|
473
|
+
);
|
|
474
|
+
}
|