Add compliance page mailing list base
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Badge, IconCheckmark1, IconFolder2, IconMedal, IconPageTextLine, IconPencil, IconPeopleAdd, IconSettingsGear2, IconStore, PageHeader, TabLink, Tabs } from "@probo/ui";
|
||||
import { Badge, IconBell2, IconCheckmark1, IconFolder2, IconMedal, IconPageTextLine, IconPencil, IconPeopleAdd, IconSettingsGear2, IconStore, PageHeader, TabLink, Tabs } from "@probo/ui";
|
||||
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
import { Outlet } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
@@ -84,6 +84,10 @@ export function CompliancePageLayout(props: { queryRef: PreloadedQuery<Complianc
|
||||
<IconPeopleAdd className="size-4" />
|
||||
{__("Access")}
|
||||
</TabLink>
|
||||
<TabLink to={`/organizations/${organizationId}/compliance-page/mailing-list`}>
|
||||
<IconBell2 className="size-4" />
|
||||
{__("Mailing List")}
|
||||
</TabLink>
|
||||
</Tabs>
|
||||
|
||||
<Outlet />
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, Card, Field, IconPlusLarge, Spinner, useDialogRef } from "@probo/ui";
|
||||
import { useState } from "react";
|
||||
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||
|
||||
import type { CompliancePageMailingListPage_updateMailingListMutation } from "#/__generated__/core/CompliancePageMailingListPage_updateMailingListMutation.graphql";
|
||||
import type { CompliancePageMailingListPageQuery } from "#/__generated__/core/CompliancePageMailingListPageQuery.graphql";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
import { CompliancePageMailingList } from "./_components/CompliancePageMailingList";
|
||||
import { NewCompliancePageSubscriberDialog } from "./_components/NewCompliancePageSubscriberDialog";
|
||||
|
||||
export const compliancePageMailingListPageQuery = graphql`
|
||||
query CompliancePageMailingListPageQuery($organizationId: ID!) {
|
||||
organization: node(id: $organizationId) {
|
||||
__typename
|
||||
... on Organization {
|
||||
compliancePage: trustCenter @required(action: THROW) {
|
||||
id
|
||||
mailingList {
|
||||
id
|
||||
replyTo
|
||||
}
|
||||
...CompliancePageMailingListFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const updateMailingListMutation = graphql`
|
||||
mutation CompliancePageMailingListPage_updateMailingListMutation($input: UpdateMailingListInput!) {
|
||||
updateMailingList(input: $input) {
|
||||
mailingList {
|
||||
id
|
||||
replyTo
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function CompliancePageMailingListPage(props: {
|
||||
queryRef: PreloadedQuery<CompliancePageMailingListPageQuery>;
|
||||
}) {
|
||||
const { queryRef } = props;
|
||||
const { __ } = useTranslate();
|
||||
const dialogRef = useDialogRef();
|
||||
|
||||
const { organization } = usePreloadedQuery<CompliancePageMailingListPageQuery>(
|
||||
compliancePageMailingListPageQuery,
|
||||
queryRef,
|
||||
);
|
||||
|
||||
if (organization.__typename !== "Organization") {
|
||||
throw new Error("invalid type for node");
|
||||
}
|
||||
|
||||
const mailingList = organization.compliancePage.mailingList;
|
||||
const mailingListId = mailingList?.id;
|
||||
|
||||
const connectionId = mailingListId
|
||||
? ConnectionHandler.getConnectionID(mailingListId, "CompliancePageMailingList_subscribers")
|
||||
: null;
|
||||
|
||||
const [replyTo, setReplyTo] = useState(mailingList?.replyTo ?? "");
|
||||
|
||||
const [updateMailingList, isUpdating] = useMutationWithToasts<CompliancePageMailingListPage_updateMailingListMutation>(
|
||||
updateMailingListMutation,
|
||||
{
|
||||
successMessage: __("Mailing list updated successfully"),
|
||||
errorMessage: __("Failed to update mailing list"),
|
||||
},
|
||||
);
|
||||
|
||||
const handleSaveReplyTo = () => {
|
||||
if (!mailingListId) return;
|
||||
void updateMailingList({
|
||||
variables: {
|
||||
input: {
|
||||
id: mailingListId,
|
||||
replyTo: replyTo.trim() || null,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{mailingListId && (
|
||||
<Card className="p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-medium">{__("Settings")}</h3>
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("Configure how your mailing list behaves")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<Field
|
||||
label={__("Reply-to email")}
|
||||
type="email"
|
||||
placeholder={__("security@example.com")}
|
||||
value={replyTo}
|
||||
onChange={e => setReplyTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSaveReplyTo}
|
||||
disabled={isUpdating}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isUpdating && <Spinner />}
|
||||
{__("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-medium">{__("Subscribers")}</h3>
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("People subscribed to receive security and compliance updates")}
|
||||
</p>
|
||||
</div>
|
||||
{mailingListId && (
|
||||
<Button icon={IconPlusLarge} onClick={() => dialogRef.current?.open()}>
|
||||
{__("Add Subscriber")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CompliancePageMailingList fragmentRef={organization.compliancePage} />
|
||||
</div>
|
||||
|
||||
{mailingListId && connectionId && (
|
||||
<NewCompliancePageSubscriberDialog
|
||||
ref={dialogRef}
|
||||
mailingListId={mailingListId}
|
||||
connectionId={connectionId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
|
||||
import type { CompliancePageMailingListPageQuery } from "#/__generated__/core/CompliancePageMailingListPageQuery.graphql";
|
||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
|
||||
|
||||
import {
|
||||
CompliancePageMailingListPage,
|
||||
compliancePageMailingListPageQuery,
|
||||
} from "./CompliancePageMailingListPage";
|
||||
|
||||
function CompliancePageMailingListPageQueryLoader() {
|
||||
const organizationId = useOrganizationId();
|
||||
const [queryRef, loadQuery] = useQueryLoader<CompliancePageMailingListPageQuery>(
|
||||
compliancePageMailingListPageQuery,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryRef) {
|
||||
loadQuery({ organizationId });
|
||||
}
|
||||
});
|
||||
|
||||
if (!queryRef) return <LinkCardSkeleton />;
|
||||
|
||||
return <CompliancePageMailingListPage queryRef={queryRef} />;
|
||||
}
|
||||
|
||||
export default function CompliancePageMailingListPageLoader() {
|
||||
return (
|
||||
<CoreRelayProvider>
|
||||
<CompliancePageMailingListPageQueryLoader />
|
||||
</CoreRelayProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Badge, Button, IconChevronDown, IconTrashCan, Spinner, Table, Tbody, Td, Th, Thead, Tr } from "@probo/ui";
|
||||
import { usePaginationFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { CompliancePageMailingListDeleteMutation } from "#/__generated__/core/CompliancePageMailingListDeleteMutation.graphql";
|
||||
import type { CompliancePageMailingListFragment$key } from "#/__generated__/core/CompliancePageMailingListFragment.graphql";
|
||||
import type { CompliancePageMailingListQuery } from "#/__generated__/core/CompliancePageMailingListQuery.graphql";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
const deleteMutation = graphql`
|
||||
mutation CompliancePageMailingListDeleteMutation(
|
||||
$input: DeleteMailingListSubscriberInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
deleteMailingListSubscriber(input: $input) {
|
||||
deletedMailingListSubscriberId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const fragment = graphql`
|
||||
fragment CompliancePageMailingListFragment on TrustCenter
|
||||
@argumentDefinitions(
|
||||
first: { type: Int, defaultValue: 20 }
|
||||
after: { type: CursorKey, defaultValue: null }
|
||||
)
|
||||
@refetchable(queryName: "CompliancePageMailingListQuery") {
|
||||
mailingList {
|
||||
id
|
||||
subscribers(
|
||||
first: $first
|
||||
after: $after
|
||||
) @connection(key: "CompliancePageMailingList_subscribers") {
|
||||
__id
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
fullName
|
||||
email
|
||||
status
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function CompliancePageMailingList(props: {
|
||||
fragmentRef: CompliancePageMailingListFragment$key;
|
||||
}) {
|
||||
const { fragmentRef } = props;
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const {
|
||||
data,
|
||||
hasNext,
|
||||
loadNext,
|
||||
isLoadingNext,
|
||||
} = usePaginationFragment<CompliancePageMailingListQuery, CompliancePageMailingListFragment$key>(
|
||||
fragment,
|
||||
fragmentRef,
|
||||
);
|
||||
|
||||
const subscribers = data.mailingList?.subscribers;
|
||||
|
||||
const [deleteSubscriber, isDeleting] = useMutationWithToasts<CompliancePageMailingListDeleteMutation>(
|
||||
deleteMutation,
|
||||
{
|
||||
successMessage: __("Subscriber removed successfully"),
|
||||
errorMessage: __("Failed to delete subscriber"),
|
||||
},
|
||||
);
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (!subscribers) return;
|
||||
void deleteSubscriber({
|
||||
variables: {
|
||||
input: { id },
|
||||
connections: [subscribers.__id],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!subscribers || subscribers.edges.length === 0
|
||||
? (
|
||||
<Table>
|
||||
<Tbody>
|
||||
<Tr>
|
||||
<Td className="text-center text-txt-tertiary py-8">
|
||||
{__("No mailing list subscribers yet")}
|
||||
</Td>
|
||||
</Tr>
|
||||
</Tbody>
|
||||
</Table>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{__("Name")}</Th>
|
||||
<Th>{__("Email")}</Th>
|
||||
<Th>{__("Status")}</Th>
|
||||
<Th>{__("Subscribed on")}</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{subscribers.edges.map(({ node: subscriber }) => (
|
||||
<Tr key={subscriber.id}>
|
||||
<Td>{subscriber.fullName}</Td>
|
||||
<Td>{subscriber.email}</Td>
|
||||
<Td>
|
||||
<Badge
|
||||
variant={subscriber.status === "CONFIRMED" ? "success" : "warning"}
|
||||
>
|
||||
{subscriber.status === "CONFIRMED" ? __("Confirmed") : __("Pending")}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td className="text-txt-tertiary text-sm">
|
||||
{new Date(subscriber.createdAt).toLocaleDateString()}
|
||||
</Td>
|
||||
<Td className="w-10">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
icon={IconTrashCan}
|
||||
disabled={isDeleting}
|
||||
onClick={() => handleDelete(subscriber.id)}
|
||||
aria-label={__("Delete subscriber")}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{hasNext && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => loadNext(10)}
|
||||
disabled={isLoadingNext}
|
||||
className="mt-3 mx-auto"
|
||||
icon={IconChevronDown}
|
||||
>
|
||||
{isLoadingNext && <Spinner />}
|
||||
{__("Show More")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, type DialogRef, Field, Spinner } from "@probo/ui";
|
||||
import { type DataID, graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { NewCompliancePageSubscriberDialogMutation } from "#/__generated__/core/NewCompliancePageSubscriberDialogMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
const createSubscriberMutation = graphql`
|
||||
mutation NewCompliancePageSubscriberDialogMutation(
|
||||
$input: CreateMailingListSubscriberInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
createMailingListSubscriber(input: $input) {
|
||||
mailingListSubscriberEdge @prependEdge(connections: $connections) {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
fullName
|
||||
email
|
||||
status
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function NewCompliancePageSubscriberDialog(props: {
|
||||
mailingListId: string;
|
||||
connectionId: DataID;
|
||||
ref: DialogRef;
|
||||
}) {
|
||||
const { mailingListId, connectionId, ref } = props;
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const schema = z.object({
|
||||
fullName: z.string().min(1, __("Full name is required")).trim(),
|
||||
email: z
|
||||
.string()
|
||||
.min(1, __("Email is required"))
|
||||
.trim()
|
||||
.email(__("Please enter a valid email address")),
|
||||
});
|
||||
|
||||
const form = useFormWithSchema(schema, {
|
||||
defaultValues: { fullName: "", email: "" },
|
||||
});
|
||||
|
||||
const [createSubscriber, isCreating] = useMutationWithToasts<NewCompliancePageSubscriberDialogMutation>(
|
||||
createSubscriberMutation,
|
||||
{
|
||||
successMessage: __("Subscriber added successfully"),
|
||||
errorMessage: __("Failed to add subscriber"),
|
||||
},
|
||||
);
|
||||
|
||||
const handleSubmit = async (data: z.infer<typeof schema>) => {
|
||||
await createSubscriber({
|
||||
variables: {
|
||||
input: {
|
||||
mailingListId,
|
||||
fullName: data.fullName.trim(),
|
||||
email: data.email.trim(),
|
||||
},
|
||||
connections: connectionId ? [connectionId] : [],
|
||||
},
|
||||
onCompleted: (_, errors) => {
|
||||
if (errors?.length) return;
|
||||
setTimeout(() => {
|
||||
form.reset();
|
||||
ref.current?.close();
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
form.reset();
|
||||
}, 300);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog ref={ref} title={__("Add Subscriber")}>
|
||||
<form onSubmit={e => void form.handleSubmit(handleSubmit)(e)}>
|
||||
<DialogContent padded className="space-y-6">
|
||||
<p className="text-txt-secondary text-sm">
|
||||
{__("Add a person to receive security and compliance updates")}
|
||||
</p>
|
||||
<Field
|
||||
label={__("Full Name")}
|
||||
required
|
||||
error={form.formState.errors.fullName?.message}
|
||||
{...form.register("fullName")}
|
||||
placeholder={__("John Doe")}
|
||||
/>
|
||||
<Field
|
||||
label={__("Email Address")}
|
||||
required
|
||||
error={form.formState.errors.email?.message}
|
||||
type="email"
|
||||
{...form.register("email")}
|
||||
placeholder={__("john@example.com")}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isCreating}>
|
||||
{isCreating && <Spinner />}
|
||||
{__("Add Subscriber")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,11 @@ export const compliancePageRoutes = [
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(() => import("#/pages/organizations/compliance-page/access/CompliancePageAccessPageLoader")),
|
||||
},
|
||||
{
|
||||
path: "mailing-list",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(() => import("#/pages/organizations/compliance-page/mailing-list/CompliancePageMailingListPageLoader")),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user