Change compliance update modals

- Merge new and edit modals design
- Create a send modal

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-03-12 14:23:24 +01:00
parent 5ac6511f63
commit 4fbb5acb70
9 changed files with 315 additions and 306 deletions

View File

@@ -10,9 +10,8 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { CompliancePageMailingList } from "./_components/CompliancePageMailingList";
import { CompliancePageUpdatesList, type UpdateNode } from "./_components/CompliancePageUpdatesList";
import { EditComplianceUpdateDialog } from "./_components/EditComplianceUpdateDialog";
import { ComplianceUpdateFormDialog } from "./_components/ComplianceUpdateFormDialog";
import { NewCompliancePageSubscriberDialog } from "./_components/NewCompliancePageSubscriberDialog";
import { NewComplianceUpdateDialog } from "./_components/NewComplianceUpdateDialog";
export const compliancePageMailingListPageQuery = graphql`
query CompliancePageMailingListPageQuery($organizationId: ID!) {
@@ -174,14 +173,14 @@ export function CompliancePageMailingListPage(props: {
</div>
{mailingListId && updatesConnectionId && (
<NewComplianceUpdateDialog
<ComplianceUpdateFormDialog
ref={newUpdateDialogRef}
mailingListId={mailingListId}
connectionId={updatesConnectionId}
/>
)}
<EditComplianceUpdateDialog
<ComplianceUpdateFormDialog
ref={editUpdateDialogRef}
update={selectedUpdate}
/>

View File

@@ -1,14 +1,16 @@
import { useTranslate } from "@probo/i18n";
import { Badge, Button, IconChevronDown, IconPencil, IconSend, IconTrashCan, Spinner, Table, Tbody, Td, Th, Thead, Tr, useConfirm } from "@probo/ui";
import { Badge, Button, IconChevronDown, IconPageTextLine, IconPencil, IconSend, IconTrashCan, Spinner, Table, Tbody, Td, Th, Thead, Tr, useDialogRef } from "@probo/ui";
import { useState } from "react";
import { usePaginationFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageUpdatesListDeleteMutation } from "#/__generated__/core/CompliancePageUpdatesListDeleteMutation.graphql";
import type { CompliancePageUpdatesListFragment$data, CompliancePageUpdatesListFragment$key } from "#/__generated__/core/CompliancePageUpdatesListFragment.graphql";
import type { CompliancePageUpdatesListQuery } from "#/__generated__/core/CompliancePageUpdatesListQuery.graphql";
import type { CompliancePageUpdatesListSendMutation } from "#/__generated__/core/CompliancePageUpdatesListSendMutation.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { SendUpdateDialog } from "./SendUpdateDialog";
const deleteMutation = graphql`
mutation CompliancePageUpdatesListDeleteMutation(
$input: DeleteMailingListUpdateInput!
@@ -20,20 +22,6 @@ const deleteMutation = graphql`
}
`;
const sendMutation = graphql`
mutation CompliancePageUpdatesListSendMutation($input: SendMailingListUpdateInput!) {
sendMailingListUpdate(input: $input) {
mailingListUpdate {
id
title
body
status
updatedAt
}
}
}
`;
const fragment = graphql`
fragment CompliancePageUpdatesListFragment on MailingList
@argumentDefinitions(
@@ -72,7 +60,9 @@ export function CompliancePageUpdatesList(props: {
}) {
const { fragmentRef, onEdit } = props;
const { __ } = useTranslate();
const confirm = useConfirm();
const sendDialogRef = useDialogRef();
const [updateToSend, setUpdateToSend] = useState<UpdateNode | null>(null);
const { data, hasNext, loadNext, isLoadingNext } = usePaginationFragment<
CompliancePageUpdatesListQuery,
@@ -87,12 +77,6 @@ export function CompliancePageUpdatesList(props: {
errorMessage: __("Failed to delete update"),
});
const [sendUpdate, isSending]
= useMutationWithToasts<CompliancePageUpdatesListSendMutation>(sendMutation, {
successMessage: __("Update enqueued for delivery"),
errorMessage: __("Failed to enqueue update for delivery"),
});
const handleDelete = (id: string) => {
void deleteUpdate({
variables: {
@@ -103,26 +87,13 @@ export function CompliancePageUpdatesList(props: {
};
const handleSend = (node: UpdateNode) => {
confirm(
() =>
sendUpdate({
variables: {
input: {
id: node.id,
},
},
}),
{
title: __("Send this update to all subscribers?"),
message: `"${node.title}" — ${node.body.length > 120 ? node.body.slice(0, 120) + "…" : node.body}`,
label: __("Send"),
variant: "primary",
},
);
setUpdateToSend(node);
sendDialogRef.current?.open();
};
return (
<>
<SendUpdateDialog ref={sendDialogRef} update={updateToSend} />
{connection.edges.length === 0
? (
<Table>
@@ -159,26 +130,25 @@ export function CompliancePageUpdatesList(props: {
{new Date(node.createdAt).toLocaleDateString()}
</Td>
<Td className="w-auto">
<div className="flex gap-1 justify-end">
<div className="flex gap-2 justify-end">
{node.status === "DRAFT" && (
<Button
variant="secondary"
icon={IconSend}
disabled={isSending}
onClick={() => handleSend(node)}
className="bg-green-600 text-white hover:bg-green-700 active:bg-green-800 shadow-none"
aria-label={__("Send")}
>
{__("Send")}
</Button>
)}
<Button
variant="tertiary"
icon={IconPencil}
variant="secondary"
icon={node.status === "DRAFT" ? IconPencil : IconPageTextLine}
onClick={() => onEdit(node)}
aria-label={__("Edit update")}
aria-label={node.status === "DRAFT" ? __("Edit update") : __("View update")}
/>
<Button
variant="tertiary"
variant="danger"
icon={IconTrashCan}
disabled={isDeleting}
onClick={() => handleDelete(node.id)}

View File

@@ -0,0 +1,180 @@
import { useTranslate } from "@probo/i18n";
import { Button, Dialog, DialogContent, DialogFooter, type DialogRef, Field, IconCircleInfo, Spinner, Textarea } from "@probo/ui";
import { useEffect } from "react";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { ComplianceUpdateFormDialogCreateMutation } from "#/__generated__/core/ComplianceUpdateFormDialogCreateMutation.graphql";
import type { ComplianceUpdateFormDialogUpdateMutation } from "#/__generated__/core/ComplianceUpdateFormDialogUpdateMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import type { UpdateNode } from "./CompliancePageUpdatesList";
const createMutation = graphql`
mutation ComplianceUpdateFormDialogCreateMutation(
$input: CreateMailingListUpdateInput!
$connections: [ID!]!
) {
createMailingListUpdate(input: $input) {
mailingListUpdate
@prependNode(connections: $connections, edgeTypeName: "MailingListUpdateEdge") {
id
title
body
status
createdAt
updatedAt
}
}
}
`;
const updateMutation = graphql`
mutation ComplianceUpdateFormDialogUpdateMutation($input: UpdateMailingListUpdateInput!) {
updateMailingListUpdate(input: $input) {
mailingListUpdate {
id
title
body
status
updatedAt
}
}
}
`;
type CreateProps = {
ref: DialogRef;
mailingListId: string;
connectionId: string;
onCreated?: () => void;
update?: never;
};
type EditProps = {
ref: DialogRef;
update: UpdateNode | null;
mailingListId?: never;
connectionId?: never;
onCreated?: never;
};
type Props = CreateProps | EditProps;
export function ComplianceUpdateFormDialog(props: Props) {
const { ref, update, mailingListId, connectionId, onCreated } = props;
const { __ } = useTranslate();
const isEditMode = update !== undefined;
const isSent = isEditMode && update?.status !== "DRAFT";
const schemaWithMessages = z.object({
title: z.string().trim().min(1, __("Title is required")),
body: z.string().trim().min(1, __("Body is required")),
});
const form = useFormWithSchema(schemaWithMessages, {
defaultValues: { title: "", body: "" },
});
useEffect(() => {
if (update) {
form.reset({ title: update.title, body: update.body });
}
}, [update, form]);
const [createUpdate, isCreating] = useMutationWithToasts<ComplianceUpdateFormDialogCreateMutation>(
createMutation,
{
successMessage: __("Update created successfully"),
errorMessage: __("Failed to create update"),
},
);
const [saveUpdate, isSaving] = useMutationWithToasts<ComplianceUpdateFormDialogUpdateMutation>(
updateMutation,
{
successMessage: __("Update saved successfully"),
errorMessage: __("Failed to save update"),
},
);
const handleSubmit = async (data: z.infer<typeof schemaWithMessages>) => {
if (isEditMode) {
if (!update) return;
await saveUpdate({
variables: {
input: {
id: update.id,
title: data.title,
body: data.body,
},
},
onCompleted: (_, errors) => {
if (!errors?.length) {
ref.current?.close();
}
},
});
} else {
await createUpdate({
variables: {
input: {
mailingListId: mailingListId,
title: data.title,
body: data.body,
},
connections: [connectionId],
},
onCompleted: (_, errors) => {
if (!errors?.length) {
form.reset();
ref.current?.close();
onCreated?.();
}
},
});
}
};
return (
<Dialog ref={ref} title={isSent ? __("View Update") : isEditMode ? __("Edit Update") : __("Add Update")}>
<form onSubmit={e => void form.handleSubmit(handleSubmit)(e)}>
<DialogContent className="px-6 pt-4 pb-2 space-y-4">
<div className="flex gap-2.5 rounded-lg bg-surface-secondary p-3 text-sm text-txt-secondary">
<IconCircleInfo size={16} className="mt-0.5 shrink-0 text-txt-tertiary" />
{__("Do not include confidential or sensitive information in this update. Any content that requires protection should be placed behind your NDA-gated documents instead.")}
</div>
<Field
label={__("Title")}
required
disabled={isSent}
error={form.formState.errors.title?.message}
{...form.register("title")}
/>
<Field
label={__("Body")}
required
error={form.formState.errors.body?.message}
>
<Textarea rows={12} disabled={isSent} {...form.register("body")} />
</Field>
{isSent && (
<p className="text-sm text-txt-tertiary">
{__("This update has been sent and can no longer be edited.")}
</p>
)}
</DialogContent>
<DialogFooter>
{!isSent && (
<Button type="submit" disabled={isCreating || isSaving}>
{(isCreating || isSaving) && <Spinner />}
{isEditMode ? __("Save") : __("Create")}
</Button>
)}
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -1,118 +0,0 @@
import { useTranslate } from "@probo/i18n";
import { Button, Dialog, DialogContent, DialogFooter, type DialogRef, Field, IconCircleInfo, Spinner, Textarea } from "@probo/ui";
import { useEffect } from "react";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { EditComplianceUpdateDialogMutation } from "#/__generated__/core/EditComplianceUpdateDialogMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import type { UpdateNode } from "./CompliancePageUpdatesList";
const updateMutation = graphql`
mutation EditComplianceUpdateDialogMutation($input: UpdateMailingListUpdateInput!) {
updateMailingListUpdate(input: $input) {
mailingListUpdate {
id
title
body
status
updatedAt
}
}
}
`;
export function EditComplianceUpdateDialog(props: {
update: UpdateNode | null;
ref: DialogRef;
}) {
const { update, ref } = props;
const { __ } = useTranslate();
const isSent = update?.status !== "DRAFT";
const schema = z.object({
title: z.string().trim().min(1, __("Title is required")),
body: z.string().trim().min(1, __("Body is required")),
});
const form = useFormWithSchema(schema, {
defaultValues: { title: "", body: "" },
});
useEffect(() => {
if (update) {
form.reset({ title: update.title, body: update.body });
}
}, [update, form]);
const [saveUpdate, isSaving] = useMutationWithToasts<EditComplianceUpdateDialogMutation>(
updateMutation,
{
successMessage: __("Update saved successfully"),
errorMessage: __("Failed to save update"),
},
);
const handleSubmit = async (data: { title: string; body: string }) => {
if (!update) return;
await saveUpdate({
variables: {
input: {
id: update.id,
title: data.title.trim(),
body: data.body.trim(),
},
},
onCompleted: (_, errors) => {
if (!errors?.length) {
ref.current?.close();
}
},
});
};
return (
<Dialog ref={ref} title={__("Edit Update")}>
<form onSubmit={e => void form.handleSubmit(handleSubmit)(e)}>
<DialogContent padded className="space-y-6">
<div className="flex gap-2.5 rounded-lg bg-surface-secondary p-3 text-sm text-txt-secondary">
<IconCircleInfo size={16} className="mt-0.5 shrink-0 text-txt-tertiary" />
<p>
{__("Do not include confidential or sensitive information in this update. Any content that requires protection should be placed behind your NDA-gated documents instead.")}
</p>
</div>
<Field
label={__("Title")}
required
disabled={isSent}
error={form.formState.errors.title?.message}
{...form.register("title")}
/>
<Field
label={__("Body")}
required
error={form.formState.errors.body?.message}
>
<Textarea rows={6} disabled={isSent} {...form.register("body")} />
</Field>
{isSent && (
<p className="text-sm text-txt-tertiary">
{__("This update has been sent and can no longer be edited.")}
</p>
)}
</DialogContent>
<DialogFooter>
{!isSent && (
<Button type="submit" disabled={isSaving}>
{isSaving && <Spinner />}
{__("Save")}
</Button>
)}
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -1,102 +0,0 @@
import { useTranslate } from "@probo/i18n";
import { Button, Dialog, DialogContent, DialogFooter, type DialogRef, Field, Spinner, Textarea } from "@probo/ui";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { NewComplianceUpdateDialogMutation } from "#/__generated__/core/NewComplianceUpdateDialogMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const createMutation = graphql`
mutation NewComplianceUpdateDialogMutation(
$input: CreateMailingListUpdateInput!
$connections: [ID!]!
) {
createMailingListUpdate(input: $input) {
mailingListUpdate
@prependNode(connections: $connections, edgeTypeName: "MailingListUpdateEdge") {
id
title
body
status
createdAt
updatedAt
}
}
}
`;
export function NewComplianceUpdateDialog(props: {
mailingListId: string;
connectionId: string;
ref: DialogRef;
onCreated?: () => void;
}) {
const { mailingListId, connectionId, ref, onCreated } = props;
const { __ } = useTranslate();
const schema = z.object({
title: z.string().trim().min(1, __("Title is required")),
body: z.string().trim().min(1, __("Body is required")),
});
const form = useFormWithSchema(schema, {
defaultValues: { title: "", body: "" },
});
const [createUpdate, isCreating] = useMutationWithToasts<NewComplianceUpdateDialogMutation>(
createMutation,
{
successMessage: __("Update created successfully"),
errorMessage: __("Failed to create update"),
},
);
const handleSubmit = async (data: { title: string; body: string }) => {
await createUpdate({
variables: {
input: {
mailingListId,
title: data.title.trim(),
body: data.body.trim(),
},
connections: [connectionId],
},
onCompleted: (_, errors) => {
if (!errors?.length) {
form.reset();
ref.current?.close();
onCreated?.();
}
},
});
};
return (
<Dialog ref={ref} title={__("Add Update")}>
<form onSubmit={e => void form.handleSubmit(handleSubmit)(e)}>
<DialogContent padded className="space-y-6">
<Field
label={__("Title")}
required
error={form.formState.errors.title?.message}
{...form.register("title")}
/>
<Field
label={__("Body")}
required
error={form.formState.errors.body?.message}
>
<Textarea rows={6} {...form.register("body")} />
</Field>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isCreating}>
{isCreating && <Spinner />}
{__("Create")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,80 @@
import { useTranslate } from "@probo/i18n";
import { Button, Dialog, DialogContent, DialogFooter, type DialogRef, IconSend, Spinner } from "@probo/ui";
import { graphql } from "relay-runtime";
import type { SendUpdateDialogMutation } from "#/__generated__/core/SendUpdateDialogMutation.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import type { UpdateNode } from "./CompliancePageUpdatesList";
const sendMutation = graphql`
mutation SendUpdateDialogMutation($input: SendMailingListUpdateInput!) {
sendMailingListUpdate(input: $input) {
mailingListUpdate {
id
title
body
status
updatedAt
}
}
}
`;
type Props = {
ref: DialogRef;
update: UpdateNode | null;
onSent?: () => void;
};
export function SendUpdateDialog({ ref, update, onSent }: Props) {
const { __ } = useTranslate();
const [sendUpdate, isSending] = useMutationWithToasts<SendUpdateDialogMutation>(sendMutation, {
successMessage: __("Update enqueued for delivery"),
errorMessage: __("Failed to enqueue update for delivery"),
});
const handleSend = async () => {
if (!update) return;
await sendUpdate({
variables: { input: { id: update.id } },
onCompleted: (_, errors) => {
if (!errors?.length) {
ref.current?.close();
onSent?.();
}
},
});
};
return (
<Dialog ref={ref} title={__("Send Update to Subscribers")}>
<DialogContent className="px-6 pt-5 pb-2 space-y-4">
<p className="text-sm text-txt-secondary">
{__("This update will be sent to all current subscribers of this mailing list. This action cannot be undone.")}
</p>
{update && (
<div className="rounded-lg border border-border-low bg-surface-secondary overflow-hidden">
<div className="px-4 py-3 border-b border-border-low">
<p className="text-sm font-medium text-txt-primary">{update.title}</p>
</div>
<div className="px-4 py-3">
<p className="text-sm text-txt-secondary whitespace-pre-wrap">{update.body}</p>
</div>
</div>
)}
</DialogContent>
<DialogFooter>
<Button
icon={IconSend}
disabled={isSending}
onClick={() => void handleSend()}
>
{isSending && <Spinner />}
{__("Send")}
</Button>
</DialogFooter>
</Dialog>
);
}