Fix apps/console lint issues
Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
@@ -26,8 +26,8 @@ export function PageError({ resetErrorBoundary, error: propsError }: Props) {
|
||||
// Reset error boundary on page change
|
||||
useEffect(() => {
|
||||
if (
|
||||
location.pathname !== baseLocation.current.pathname &&
|
||||
resetErrorBoundary
|
||||
location.pathname !== baseLocation.current.pathname
|
||||
&& resetErrorBoundary
|
||||
) {
|
||||
resetErrorBoundary();
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export function PageError({ resetErrorBoundary, error: propsError }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!error || (error && error.toString().includes("PAGE_NOT_FOUND"))) {
|
||||
if (!error || (error instanceof Error && error.message.includes("PAGE_NOT_FOUND"))) {
|
||||
return (
|
||||
<div className={classNames.wrapper}>
|
||||
<h1 className={classNames.title}>
|
||||
@@ -68,7 +68,7 @@ export function PageError({ resetErrorBoundary, error: propsError }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error && error.toString().includes("FORBIDDEN")) {
|
||||
if (error instanceof Error && error.message.includes("FORBIDDEN")) {
|
||||
return (
|
||||
<div className={classNames.wrapper}>
|
||||
<h1 className={classNames.title}>
|
||||
@@ -82,7 +82,7 @@ export function PageError({ resetErrorBoundary, error: propsError }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error && error.toString().includes("UNAUTHORIZED")) {
|
||||
if (error instanceof Error && error.message.includes("UNAUTHORIZED")) {
|
||||
return (
|
||||
<div className={classNames.wrapper}>
|
||||
<h1 className={classNames.title}>
|
||||
@@ -103,7 +103,8 @@ export function PageError({ resetErrorBoundary, error: propsError }: Props) {
|
||||
<summary className={classNames.description}>
|
||||
{__("Something went wrong")}
|
||||
</summary>
|
||||
<p className={classNames.detail}>{error.toString()}</p>
|
||||
{error instanceof Error
|
||||
&& <p className={classNames.detail}>{error.message}</p>}
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -46,8 +46,8 @@ export function SnapshotBanner({ snapshotId }: Props) {
|
||||
}
|
||||
|
||||
if (
|
||||
snapshot.type &&
|
||||
!isSnapshotTypeValidForUrl(snapshot.type, location.pathname)
|
||||
snapshot.type
|
||||
&& !isSnapshotTypeValidForUrl(snapshot.type, location.pathname)
|
||||
) {
|
||||
throw new Error("PAGE_NOT_FOUND");
|
||||
}
|
||||
@@ -58,7 +58,9 @@ export function SnapshotBanner({ snapshotId }: Props) {
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-warning-800">
|
||||
{__("Snapshot")} {snapshot.name}
|
||||
{__("Snapshot")}
|
||||
{" "}
|
||||
{snapshot.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-warning-700">
|
||||
|
||||
@@ -141,7 +141,7 @@ export function AssetsTable(props: Props) {
|
||||
<VendorsCell
|
||||
name="vendorIds"
|
||||
organizationId={organizationId}
|
||||
defaultValue={item?.vendors?.edges?.map((edge) => edge.node) ?? []}
|
||||
defaultValue={item?.vendors?.edges?.map(edge => edge.node) ?? []}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -163,7 +163,7 @@ const useDeleteAsset = (connectionId: string) => {
|
||||
promisifyMutation(mutate)({
|
||||
variables: {
|
||||
input: {
|
||||
assetId: asset.id!,
|
||||
assetId: asset.id,
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
@@ -171,7 +171,7 @@ const useDeleteAsset = (connectionId: string) => {
|
||||
{
|
||||
message: sprintf(
|
||||
__(
|
||||
'This will permanently delete "%s". This action cannot be undone.',
|
||||
"This will permanently delete \"%s\". This action cannot be undone.",
|
||||
),
|
||||
asset.name,
|
||||
),
|
||||
|
||||
@@ -39,7 +39,7 @@ export function ReadOnlyAssetsTable(props: Props) {
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{assets.map((entry) => (
|
||||
{assets.map(entry => (
|
||||
<AssetRow key={entry.id} entry={entry} />
|
||||
))}
|
||||
</Tbody>
|
||||
@@ -51,7 +51,7 @@ function AssetRow({ entry }: { entry: AssetEntry }) {
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
||||
const vendors = entry.vendors?.edges.map((edge) => edge.node) ?? [];
|
||||
const vendors = entry.vendors?.edges.map(edge => edge.node) ?? [];
|
||||
|
||||
return (
|
||||
<Tr
|
||||
@@ -70,31 +70,34 @@ function AssetRow({ entry }: { entry: AssetEntry }) {
|
||||
<Td>{entry.amount}</Td>
|
||||
<Td>{entry.owner?.fullName ?? __("Unassigned")}</Td>
|
||||
<Td>
|
||||
{vendors.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{vendors.slice(0, 3).map((vendor) => (
|
||||
<Badge
|
||||
key={vendor.id}
|
||||
variant="neutral"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Avatar
|
||||
name={vendor.name}
|
||||
src={faviconUrl(vendor.websiteUrl)}
|
||||
size="s"
|
||||
/>
|
||||
<span className="text-xs">{vendor.name}</span>
|
||||
</Badge>
|
||||
))}
|
||||
{vendors.length > 3 && (
|
||||
<Badge variant="neutral" className="text-xs">
|
||||
+{vendors.length - 3}
|
||||
</Badge>
|
||||
{vendors.length > 0
|
||||
? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{vendors.slice(0, 3).map(vendor => (
|
||||
<Badge
|
||||
key={vendor.id}
|
||||
variant="neutral"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Avatar
|
||||
name={vendor.name}
|
||||
src={faviconUrl(vendor.websiteUrl)}
|
||||
size="s"
|
||||
/>
|
||||
<span className="text-xs">{vendor.name}</span>
|
||||
</Badge>
|
||||
))}
|
||||
{vendors.length > 3 && (
|
||||
<Badge variant="neutral" className="text-xs">
|
||||
+
|
||||
{vendors.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<span className="text-txt-secondary text-sm">{__("None")}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-txt-secondary text-sm">{__("None")}</span>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
|
||||
@@ -131,7 +131,7 @@ export function LinkedAuditsCard<Params>(props: Props<Params>) {
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{audits.map((audit) => (
|
||||
{audits.map(audit => (
|
||||
<AuditRow
|
||||
key={audit.id}
|
||||
audit={audit}
|
||||
|
||||
@@ -104,15 +104,15 @@ function LinkedAuditsDialogContent(props: Omit<Props, "children">) {
|
||||
const { __ } = useTranslate();
|
||||
const [search, setSearch] = useState("");
|
||||
const audits = useMemo(
|
||||
() => data.audits?.edges?.map((edge) => edge.node) ?? [],
|
||||
() => data.audits?.edges?.map(edge => edge.node) ?? [],
|
||||
[data.audits],
|
||||
);
|
||||
const linkedIds = useMemo(() => {
|
||||
return new Set(props.linkedAudits?.map((a) => a.id) ?? []);
|
||||
return new Set(props.linkedAudits?.map(a => a.id) ?? []);
|
||||
}, [props.linkedAudits]);
|
||||
|
||||
const filteredAudits = useMemo(() => {
|
||||
return audits.filter((audit) =>
|
||||
return audits.filter(audit =>
|
||||
(audit.name || "").toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
}, [audits, search]);
|
||||
@@ -127,7 +127,7 @@ function LinkedAuditsDialogContent(props: Omit<Props, "children">) {
|
||||
/>
|
||||
</div>
|
||||
<div className="divide-y divide-border-low">
|
||||
{filteredAudits.map((audit) => (
|
||||
{filteredAudits.map(audit => (
|
||||
<AuditRow
|
||||
key={audit.id}
|
||||
audit={audit}
|
||||
@@ -186,7 +186,9 @@ function AuditRow(props: RowProps) {
|
||||
asChild
|
||||
>
|
||||
<span>
|
||||
<IconComponent size={16} /> {isLinked ? __("Unlink") : __("Link")}
|
||||
<IconComponent size={16} />
|
||||
{" "}
|
||||
{isLinked ? __("Unlink") : __("Link")}
|
||||
</span>
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
@@ -111,7 +111,7 @@ export function LinkedControlsCard<Params>(props: Props<Params>) {
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{controls.map((control) => (
|
||||
{controls.map(control => (
|
||||
<ControlRow
|
||||
key={control.id}
|
||||
control={control}
|
||||
@@ -152,7 +152,8 @@ function ControlRow(props: {
|
||||
>
|
||||
<Td>
|
||||
<span className="inline-flex gap-2 items-center">
|
||||
{control.framework.name}{" "}
|
||||
{control.framework.name}
|
||||
{" "}
|
||||
<Badge size="md">{control.sectionTitle}</Badge>
|
||||
</span>
|
||||
</Td>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@probo/ui";
|
||||
import {
|
||||
Suspense,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -104,11 +105,11 @@ export function LinkedControlsDialog(props: Props) {
|
||||
</div>
|
||||
<div ref={contentRef}>
|
||||
<Suspense
|
||||
fallback={
|
||||
fallback={(
|
||||
<div style={{ minHeight }}>
|
||||
<Spinner centered />
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
>
|
||||
<LinkedControlsDialogContent {...props} ref={searchRef} />
|
||||
</Suspense>
|
||||
@@ -123,32 +124,37 @@ function LinkedControlsDialogContent(props: Props & { ref: SearchRef }) {
|
||||
const mainData = useLazyLoadQuery<LinkedControlsDialogQuery>(query, {
|
||||
organizationId,
|
||||
});
|
||||
const { data, loadNext, hasNext, isLoadingNext, refetch } =
|
||||
usePaginationFragment(
|
||||
controlsFragment,
|
||||
mainData.organization as LinkedControlsDialogFragment$key,
|
||||
);
|
||||
const { data, loadNext, hasNext, isLoadingNext, refetch } = usePaginationFragment(
|
||||
controlsFragment,
|
||||
mainData.organization as LinkedControlsDialogFragment$key,
|
||||
);
|
||||
|
||||
const controls = data.controls?.edges?.map((edge) => edge.node) ?? [];
|
||||
const controls = data.controls?.edges?.map(edge => edge.node) ?? [];
|
||||
const controlIds = useMemo(() => {
|
||||
return new Set(props.linkedControls?.map((c) => c.id) ?? []);
|
||||
return new Set(props.linkedControls?.map(c => c.id) ?? []);
|
||||
}, [props.linkedControls]);
|
||||
|
||||
props.ref.current = {
|
||||
search: useDebounceCallback((v: string) => {
|
||||
refetch({
|
||||
first: 20,
|
||||
filter: {
|
||||
query: v,
|
||||
},
|
||||
});
|
||||
}, 500),
|
||||
};
|
||||
const handleSearch = useDebounceCallback((v: string) => {
|
||||
refetch({
|
||||
first: 20,
|
||||
filter: {
|
||||
query: v,
|
||||
},
|
||||
});
|
||||
}, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.ref.current) {
|
||||
props.ref.current = {
|
||||
search: handleSearch,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="divide-y divide-border-low">
|
||||
{controls.map((control) => (
|
||||
{controls.map(control => (
|
||||
<ControlRow
|
||||
key={control.id}
|
||||
control={control}
|
||||
@@ -182,7 +188,10 @@ function ControlRow(
|
||||
className="py-4 flex items-center gap-4 hover:bg-subtle cursor-pointer px-6 w-full text-start"
|
||||
onClick={() => onClick(props.control.id)}
|
||||
>
|
||||
{props.control.sectionTitle} : {props.control.name}
|
||||
{props.control.sectionTitle}
|
||||
{" "}
|
||||
:
|
||||
{props.control.name}
|
||||
<Badge>{props.control.framework.name}</Badge>
|
||||
<Button
|
||||
disabled={props.disabled}
|
||||
@@ -191,7 +200,9 @@ function ControlRow(
|
||||
asChild
|
||||
>
|
||||
<span>
|
||||
<IconComponent size={16} /> {isLinked ? __("Unlink") : __("Link")}
|
||||
<IconComponent size={16} />
|
||||
{" "}
|
||||
{isLinked ? __("Unlink") : __("Link")}
|
||||
</span>
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
@@ -35,7 +35,7 @@ type BulkExportFormData = z.infer<typeof bulkExportSchema>;
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
onExport: (options: BulkExportFormData) => void;
|
||||
onExport: (options: BulkExportFormData) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
defaultEmail: string;
|
||||
selectedCount: number;
|
||||
@@ -59,7 +59,7 @@ export const BulkExportDialog = forwardRef<BulkExportDialogRef, Props>(
|
||||
watermarkEmail: defaultEmail,
|
||||
withSignatures: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const watchWatermark = watch("withWatermark");
|
||||
@@ -70,14 +70,14 @@ export const BulkExportDialog = forwardRef<BulkExportDialogRef, Props>(
|
||||
close: () => dialogRef.current?.close(),
|
||||
}));
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
const onSubmit = async (data: BulkExportFormData) => {
|
||||
const options = {
|
||||
...data,
|
||||
watermarkEmail: data.withWatermark ? data.watermarkEmail : undefined,
|
||||
};
|
||||
onExport(options);
|
||||
await onExport(options);
|
||||
dialogRef.current?.close();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -87,13 +87,13 @@ export const BulkExportDialog = forwardRef<BulkExportDialogRef, Props>(
|
||||
ref={dialogRef}
|
||||
title={sprintf(__("Export %s Documents"), selectedCount)}
|
||||
>
|
||||
<form onSubmit={onSubmit}>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent className="space-y-4" padded>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={watchSignatures}
|
||||
onChange={(checked) => setValue("withSignatures", checked)}
|
||||
onChange={checked => setValue("withSignatures", checked)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium text-txt-primary cursor-pointer">
|
||||
@@ -108,7 +108,7 @@ export const BulkExportDialog = forwardRef<BulkExportDialogRef, Props>(
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={watchWatermark}
|
||||
onChange={(checked) => setValue("withWatermark", checked)}
|
||||
onChange={checked => setValue("withWatermark", checked)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium text-txt-primary cursor-pointer">
|
||||
@@ -146,19 +146,23 @@ export const BulkExportDialog = forwardRef<BulkExportDialogRef, Props>(
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner size={16} />
|
||||
{__("Exporting...")}
|
||||
</>
|
||||
) : (
|
||||
__("Export Documents")
|
||||
)}
|
||||
{isLoading
|
||||
? (
|
||||
<>
|
||||
<Spinner size={16} />
|
||||
{__("Exporting...")}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
__("Export Documents")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
BulkExportDialog.displayName = "BulkExportDialog";
|
||||
|
||||
@@ -145,7 +145,7 @@ export function LinkedDocumentsCard<Params>(props: Props<Params>) {
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{documents.map((document) => (
|
||||
{documents.map(document => (
|
||||
<DocumentRow
|
||||
key={document.id}
|
||||
document={document}
|
||||
|
||||
@@ -102,15 +102,15 @@ function LinkedDocumentsDialogContent(props: Omit<Props, "children">) {
|
||||
const { __ } = useTranslate();
|
||||
const [search, setSearch] = useState("");
|
||||
const documents = useMemo(
|
||||
() => data.documents?.edges?.map((edge) => edge.node) ?? [],
|
||||
() => data.documents?.edges?.map(edge => edge.node) ?? [],
|
||||
[data.documents],
|
||||
);
|
||||
const linkedIds = useMemo(() => {
|
||||
return new Set(props.linkedDocuments?.map((m) => m.id) ?? []);
|
||||
return new Set(props.linkedDocuments?.map(m => m.id) ?? []);
|
||||
}, [props.linkedDocuments]);
|
||||
|
||||
const filteredDocuments = useMemo(() => {
|
||||
return documents.filter((document) =>
|
||||
return documents.filter(document =>
|
||||
document.title.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
}, [documents, search]);
|
||||
@@ -125,7 +125,7 @@ function LinkedDocumentsDialogContent(props: Omit<Props, "children">) {
|
||||
/>
|
||||
</div>
|
||||
<div className="divide-y divide-border-low">
|
||||
{filteredDocuments.map((document) => (
|
||||
{filteredDocuments.map(document => (
|
||||
<DocumentRow
|
||||
key={document.id}
|
||||
document={document}
|
||||
@@ -177,7 +177,9 @@ function DocumentRow(props: RowProps) {
|
||||
asChild
|
||||
>
|
||||
<span>
|
||||
<IconComponent size={16} /> {isLinked ? __("Unlink") : __("Link")}
|
||||
<IconComponent size={16} />
|
||||
{" "}
|
||||
{isLinked ? __("Unlink") : __("Link")}
|
||||
</span>
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
@@ -15,8 +15,8 @@ import { IconMinusLarge } from "@probo/ui/src/Atoms/Icons/IconMinusLarge.tsx";
|
||||
// Worker for PDF.js
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
|
||||
|
||||
const btnClass =
|
||||
"size-8 grid place-items-center hover:bg-secondary-hover cursor-pointer rounded-sm disabled:opacity-30 transition-all";
|
||||
const btnClass
|
||||
= "size-8 grid place-items-center hover:bg-secondary-hover cursor-pointer rounded-sm disabled:opacity-30 transition-all";
|
||||
|
||||
export function PDFPreview({ src, name }: { src: string; name?: string }) {
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
@@ -92,7 +92,10 @@ export function PDFPreview({ src, name }: { src: string; name?: string }) {
|
||||
<IconChevronLeft size={16} />
|
||||
</button>
|
||||
<div>
|
||||
{currentPage} / {numPages}
|
||||
{currentPage}
|
||||
{" "}
|
||||
/
|
||||
{numPages}
|
||||
</div>
|
||||
<button onClick={movePage(1)} className={btnClass}>
|
||||
<IconChevronRight size={16} />
|
||||
@@ -123,7 +126,7 @@ export function PDFPreview({ src, name }: { src: string; name?: string }) {
|
||||
ref={documentRef}
|
||||
>
|
||||
{numPages === 0 && <Spinner className="mx-auto" />}
|
||||
{times(numPages, (index) => (
|
||||
{times(numPages, index => (
|
||||
<Page
|
||||
className="w-max h-max mx-auto shadow-mid"
|
||||
key={index.toString()}
|
||||
|
||||
@@ -42,8 +42,8 @@ export const PdfDownloadDialog = forwardRef<PdfDownloadDialogRef, Props>(
|
||||
const { __ } = useTranslate();
|
||||
const dialogRef = useDialogRef();
|
||||
|
||||
const { register, handleSubmit, formState, watch, setValue } =
|
||||
useFormWithSchema(pdfDownloadSchema, {
|
||||
const { register, handleSubmit, formState, watch, setValue }
|
||||
= useFormWithSchema(pdfDownloadSchema, {
|
||||
defaultValues: {
|
||||
withWatermark: false,
|
||||
watermarkEmail: defaultEmail,
|
||||
@@ -59,14 +59,14 @@ export const PdfDownloadDialog = forwardRef<PdfDownloadDialogRef, Props>(
|
||||
close: () => dialogRef.current?.close(),
|
||||
}));
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
const onSubmit = (data: PdfDownloadFormData) => {
|
||||
const options = {
|
||||
...data,
|
||||
watermarkEmail: data.withWatermark ? data.watermarkEmail : undefined,
|
||||
};
|
||||
onDownload(options);
|
||||
dialogRef.current?.close();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -76,13 +76,13 @@ export const PdfDownloadDialog = forwardRef<PdfDownloadDialogRef, Props>(
|
||||
ref={dialogRef}
|
||||
title={__("Download PDF Options")}
|
||||
>
|
||||
<form onSubmit={onSubmit}>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent className="space-y-4" padded>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={watchSignatures}
|
||||
onChange={(checked) => setValue("withSignatures", checked)}
|
||||
onChange={checked => setValue("withSignatures", checked)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium text-txt-primary cursor-pointer">
|
||||
@@ -99,7 +99,7 @@ export const PdfDownloadDialog = forwardRef<PdfDownloadDialogRef, Props>(
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={watchWatermark}
|
||||
onChange={(checked) => setValue("withWatermark", checked)}
|
||||
onChange={checked => setValue("withWatermark", checked)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium text-txt-primary cursor-pointer">
|
||||
@@ -130,14 +130,16 @@ export const PdfDownloadDialog = forwardRef<PdfDownloadDialogRef, Props>(
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner size={16} />
|
||||
{__("Downloading...")}
|
||||
</>
|
||||
) : (
|
||||
__("Download PDF")
|
||||
)}
|
||||
{isLoading
|
||||
? (
|
||||
<>
|
||||
<Spinner size={16} />
|
||||
{__("Downloading...")}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
__("Download PDF")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -74,10 +74,10 @@ function AuditSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
{ organizationId },
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
const audits =
|
||||
data?.organization?.audits?.edges
|
||||
?.map((edge) => edge.node)
|
||||
.filter((node) => node !== null) ?? [];
|
||||
const audits
|
||||
= data?.organization?.audits?.edges
|
||||
?.map(edge => edge.node)
|
||||
.filter(node => node !== null) ?? [];
|
||||
|
||||
const NONE_VALUE = "__NONE__";
|
||||
|
||||
@@ -91,9 +91,8 @@ function AuditSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
id={name}
|
||||
variant="editor"
|
||||
placeholder={__("Select an audit")}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === NONE_VALUE ? "" : value)
|
||||
}
|
||||
onValueChange={value =>
|
||||
field.onChange(value === NONE_VALUE ? "" : value)}
|
||||
key={audits?.length.toString() ?? "0"}
|
||||
{...field}
|
||||
className="w-full"
|
||||
@@ -102,7 +101,7 @@ function AuditSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
<Option value={NONE_VALUE}>
|
||||
<span className="text-txt-tertiary">{__("None")}</span>
|
||||
</Option>
|
||||
{audits?.map((audit) => (
|
||||
{audits?.map(audit => (
|
||||
<Option key={audit.id} value={audit.id}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span>
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { Field } from "@probo/ui";
|
||||
import { Controller, type FieldValues } from "react-hook-form";
|
||||
import { Controller, type FieldPath, type FieldValues } from "react-hook-form";
|
||||
import { Select } from "@probo/ui";
|
||||
|
||||
type Props<T extends typeof Field | typeof Select, TFieldValues extends FieldValues = FieldValues> =
|
||||
ComponentProps<T> & Omit<ComponentProps<typeof Controller<TFieldValues>>, "render">;
|
||||
type Props<
|
||||
T extends typeof Field | typeof Select,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>
|
||||
= ComponentProps<T> & Omit<ComponentProps<typeof Controller<TFieldValues, TName>>, "render">;
|
||||
|
||||
export function ControlledField<TFieldValues extends FieldValues = FieldValues>({
|
||||
export function ControlledField<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
...props
|
||||
}: Props<typeof Field, TFieldValues>) {
|
||||
}: Props<typeof Field, TFieldValues, TName>) {
|
||||
return (
|
||||
<Controller
|
||||
<Controller<TFieldValues, TName>
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
@@ -21,7 +28,7 @@ export function ControlledField<TFieldValues extends FieldValues = FieldValues>(
|
||||
{...props}
|
||||
{...field}
|
||||
// TODO : Find a better way to handle this case (comparing number and string for select create issues)
|
||||
value={field.value ? field.value.toString() : ""}
|
||||
value={field.value ? (field.value as readonly string[] | string | number).toString() : ""}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge, Input, IconCrossLargeX } from "@probo/ui";
|
||||
import { type Control, Controller, type FieldPath, type FieldValues } from "react-hook-form";
|
||||
import { getCountryName, getCountryOptions, countries, type CountryCode } from "@probo/helpers";
|
||||
@@ -35,16 +35,16 @@ type CountriesFieldInputProps = {
|
||||
|
||||
function CountriesFieldInput(props: CountriesFieldInputProps) {
|
||||
const { __ } = useTranslate();
|
||||
const animateBadge = useRef(false);
|
||||
const [animateBadge, setAnimateBadge] = useState(false);
|
||||
|
||||
const addCountry = (code: string) => {
|
||||
animateBadge.current = true;
|
||||
setAnimateBadge(true);
|
||||
props.onValueChange([...props.value, code]);
|
||||
};
|
||||
|
||||
const removeCountry = (code: string) => {
|
||||
animateBadge.current = true;
|
||||
props.onValueChange(props.value.filter((v) => v !== code));
|
||||
setAnimateBadge(true);
|
||||
props.onValueChange(props.value.filter(v => v !== code));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -52,7 +52,7 @@ function CountriesFieldInput(props: CountriesFieldInputProps) {
|
||||
{props.value.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{props.value.map((countryCode) => (
|
||||
{props.value.map(countryCode => (
|
||||
<Badge asChild size="md" key={countryCode}>
|
||||
<button
|
||||
onClick={() => removeCountry(countryCode)}
|
||||
@@ -61,8 +61,8 @@ function CountriesFieldInput(props: CountriesFieldInputProps) {
|
||||
className={clsx(
|
||||
"hover:bg-subtle-hover cursor-pointer",
|
||||
props.disabled && "opacity-50 cursor-not-allowed",
|
||||
animateBadge.current &&
|
||||
"starting:opacity-0 starting:w-0 w-max transition-all duration-500 starting:bg-accent"
|
||||
animateBadge
|
||||
&& "starting:opacity-0 starting:w-0 w-max transition-all duration-500 starting:bg-accent",
|
||||
)}
|
||||
>
|
||||
{getCountryName(__, countryCode as CountryCode)}
|
||||
@@ -78,7 +78,7 @@ function CountriesFieldInput(props: CountriesFieldInputProps) {
|
||||
{!props.disabled && (
|
||||
<CountryInput
|
||||
availableCountries={countries.filter(
|
||||
(c: CountryCode) => !props.value.includes(c)
|
||||
(c: CountryCode) => !props.value.includes(c),
|
||||
)}
|
||||
onAdd={addCountry}
|
||||
/>
|
||||
@@ -108,7 +108,7 @@ function CountryInput({ availableCountries, onAdd }: CountryInputProps) {
|
||||
const filteredCountries = countryOptions
|
||||
.filter((option: { value: string; label: string }) => availableCountries.includes(option.value as CountryCode))
|
||||
.filter((option: { value: string; label: string }) =>
|
||||
option.label.toLowerCase().includes(search.toLowerCase())
|
||||
option.label.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleCountryClick = (value: string) => {
|
||||
@@ -123,7 +123,7 @@ function CountryInput({ availableCountries, onAdd }: CountryInputProps) {
|
||||
<Input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
placeholder={__("Search and add countries...")}
|
||||
className="w-full pr-8"
|
||||
|
||||
@@ -10,7 +10,7 @@ export function DocumentClassificationOptions() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{documentClassifications.map((classification) => (
|
||||
{documentClassifications.map(classification => (
|
||||
<Option key={classification} value={classification}>
|
||||
{getDocumentClassificationLabel(__, classification)}
|
||||
</Option>
|
||||
|
||||
@@ -7,7 +7,7 @@ export function DocumentTypeOptions() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{documentTypes.map((type) => (
|
||||
{documentTypes.map(type => (
|
||||
<Option key={type} value={type}>
|
||||
{getDocumentTypeLabel(__, type)}
|
||||
</Option>
|
||||
|
||||
@@ -12,7 +12,9 @@ type Props<TFieldValues extends FieldValues = FieldValues> = {
|
||||
/**
|
||||
* A field to handle multiple emails
|
||||
*/
|
||||
export function EmailsField<TFieldValues extends FieldValues = FieldValues>({ control, register }: Props<TFieldValues>) {
|
||||
export function EmailsField<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
>({ control, register }: Props<TFieldValues>) {
|
||||
const { __ } = useTranslate();
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: "additionalEmailAddresses" as ArrayPath<TFieldValues>,
|
||||
|
||||
@@ -4,7 +4,10 @@ import { useTranslate } from "@probo/i18n";
|
||||
import { type Control, Controller, type FieldPath, type FieldValues } from "react-hook-form";
|
||||
import { usePaginatedMeasures } from "/hooks/graph/usePaginatedMeasures";
|
||||
|
||||
type Props<TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>> = {
|
||||
type Props<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
organizationId: string;
|
||||
control: Control<TFieldValues>;
|
||||
name: TName;
|
||||
@@ -39,7 +42,7 @@ export function MeasureSelectField<TFieldValues extends FieldValues = FieldValue
|
||||
}
|
||||
|
||||
function MeasureSelectWithQuery<TFieldValues extends FieldValues = FieldValues>(
|
||||
props: Pick<Props<TFieldValues>, "organizationId" | "control" | "name" | "disabled" | "optional">
|
||||
props: Pick<Props<TFieldValues>, "organizationId" | "control" | "name" | "disabled" | "optional">,
|
||||
) {
|
||||
const { __ } = useTranslate();
|
||||
const { name, organizationId, control, disabled, optional } = props;
|
||||
@@ -49,16 +52,16 @@ function MeasureSelectWithQuery<TFieldValues extends FieldValues = FieldValues>(
|
||||
return (
|
||||
data?.measures.edges
|
||||
?.filter(
|
||||
(edge) =>
|
||||
edge.node.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
edge.node.description?.toLowerCase().includes(search.toLowerCase())
|
||||
edge =>
|
||||
edge.node.name.toLowerCase().includes(search.toLowerCase())
|
||||
|| edge.node.description?.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
.map((edge) => edge.node) ?? []
|
||||
.map(edge => edge.node) ?? []
|
||||
);
|
||||
}, [data?.measures.edges, search]);
|
||||
|
||||
const allMeasures = useMemo(() => {
|
||||
return data?.measures.edges?.map((edge) => edge.node) ?? [];
|
||||
return data?.measures.edges?.map(edge => edge.node) ?? [];
|
||||
}, [data?.measures.edges]);
|
||||
|
||||
return (
|
||||
@@ -67,7 +70,7 @@ function MeasureSelectWithQuery<TFieldValues extends FieldValues = FieldValues>(
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => {
|
||||
const selectedMeasure = field.value ? allMeasures?.find((m) => m.id === field.value) : null;
|
||||
const selectedMeasure = field.value ? allMeasures?.find(m => m.id === field.value) : null;
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
@@ -87,7 +90,7 @@ function MeasureSelectWithQuery<TFieldValues extends FieldValues = FieldValues>(
|
||||
{__("None")}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
{measures?.map((m) => (
|
||||
{measures?.map(m => (
|
||||
<ComboboxItem
|
||||
key={m.id}
|
||||
onClick={() => {
|
||||
|
||||
@@ -44,7 +44,7 @@ export function PeopleMultiSelectField<T extends FieldValues = FieldValues>({
|
||||
}
|
||||
|
||||
function PeopleMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
props: Pick<Props<T>, "organizationId" | "control" | "name" | "disabled" | "selectedPeople">
|
||||
props: Pick<Props<T>, "organizationId" | "control" | "name" | "disabled" | "selectedPeople">,
|
||||
) {
|
||||
const { __ } = useTranslate();
|
||||
const { name, organizationId, control, selectedPeople = [] } = props;
|
||||
@@ -52,7 +52,7 @@ function PeopleMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const allPeople = [...people];
|
||||
selectedPeople.forEach(selectedPerson => {
|
||||
selectedPeople.forEach((selectedPerson) => {
|
||||
if (!allPeople.find(p => p.id === selectedPerson.id)) {
|
||||
allPeople.push({
|
||||
id: selectedPerson.id,
|
||||
@@ -99,7 +99,7 @@ function PeopleMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
{availablePeople.map((person) => (
|
||||
{availablePeople.map(person => (
|
||||
<Option key={person.id} value={person.id} className="flex gap-2">
|
||||
<Avatar
|
||||
name={person.fullName}
|
||||
@@ -120,7 +120,7 @@ function PeopleMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
|
||||
{selectedPeople.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedPeople.map((person) => (
|
||||
{selectedPeople.map(person => (
|
||||
<Badge key={person.id} variant="neutral" className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={person.fullName}
|
||||
@@ -153,4 +153,3 @@ function PeopleMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import { useTranslate } from "@probo/i18n";
|
||||
import { type Control, Controller, type FieldPath, type FieldValues } from "react-hook-form";
|
||||
import { usePeople } from "/hooks/graph/PeopleGraph.ts";
|
||||
|
||||
type Props<TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>> = {
|
||||
type Props<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
organizationId: string;
|
||||
control: Control<TFieldValues>;
|
||||
name: TName;
|
||||
@@ -55,16 +58,15 @@ function PeopleSelectWithQuery<TFieldValues extends FieldValues = FieldValues>(
|
||||
id={name}
|
||||
variant="editor"
|
||||
placeholder={__("Select an owner")}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === "__NONE__" ? null : value)
|
||||
}
|
||||
onValueChange={value =>
|
||||
field.onChange(value === "__NONE__" ? null : value)}
|
||||
key={people?.length.toString() ?? "0"}
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? (props.optional ? "__NONE__" : "")}
|
||||
>
|
||||
{props.optional && <Option value="__NONE__">{__("None")}</Option>}
|
||||
{people?.map((p) => (
|
||||
{people?.map(p => (
|
||||
<Option key={p.id} value={p.id} className="flex gap-2">
|
||||
<Avatar name={p.fullName} />
|
||||
{p.fullName}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function SpecialOrCriminalDataOptions() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.map((option) => (
|
||||
{options.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
@@ -47,7 +47,7 @@ export function LawfulBasisOptions() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.map((option) => (
|
||||
{options.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
@@ -113,7 +113,7 @@ export function TransferSafeguardsOptions() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.map((option) => (
|
||||
{options.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
@@ -135,7 +135,7 @@ export function DataProtectionImpactAssessmentOptions() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.map((option) => (
|
||||
{options.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
@@ -157,7 +157,7 @@ export function TransferImpactAssessmentOptions() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.map((option) => (
|
||||
{options.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
@@ -179,7 +179,7 @@ export function RoleOptions() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.map((option) => (
|
||||
{options.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
|
||||
@@ -7,7 +7,7 @@ export function SnapshotTypeOptions() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{snapshotTypes.map((type) => (
|
||||
{snapshotTypes.map(type => (
|
||||
<Option key={type} value={type}>
|
||||
{getSnapshotTypeLabel(__, type)}
|
||||
</Option>
|
||||
|
||||
@@ -1,681 +0,0 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Field,
|
||||
Select,
|
||||
Option,
|
||||
Checkbox,
|
||||
Textarea,
|
||||
Spinner,
|
||||
Button,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconTrashCan,
|
||||
IconPlusLarge,
|
||||
} from "@probo/ui";
|
||||
import { Suspense, useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
Controller,
|
||||
type Control,
|
||||
type UseFormSetValue,
|
||||
type FieldValues,
|
||||
type Path,
|
||||
type PathValue,
|
||||
} from "react-hook-form";
|
||||
import { useLazyLoadQuery } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { useOrganizationId } from "/hooks/useOrganizationId";
|
||||
import type { StateOfApplicabilityControlsFieldFrameworksQuery } from "/__generated__/core/StateOfApplicabilityControlsFieldFrameworksQuery.graphql";
|
||||
import type { StateOfApplicabilityControlsFieldFrameworkControlsQuery } from "/__generated__/core/StateOfApplicabilityControlsFieldFrameworkControlsQuery.graphql";
|
||||
|
||||
const frameworksQuery = graphql`
|
||||
query StateOfApplicabilityControlsFieldFrameworksQuery(
|
||||
$organizationId: ID!
|
||||
) {
|
||||
organization: node(id: $organizationId) {
|
||||
... on Organization {
|
||||
frameworks(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const frameworkControlsQuery = graphql`
|
||||
query StateOfApplicabilityControlsFieldFrameworkControlsQuery(
|
||||
$frameworkId: ID!
|
||||
) {
|
||||
framework: node(id: $frameworkId) {
|
||||
... on Framework {
|
||||
id
|
||||
controls(
|
||||
first: 500
|
||||
orderBy: { field: SECTION_TITLE, direction: ASC }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
sectionTitle
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type ControlSelection = {
|
||||
controlId: string;
|
||||
state: "EXCLUDED" | "IMPLEMENTED" | "NOT_IMPLEMENTED";
|
||||
exclusionJustification?: string;
|
||||
};
|
||||
|
||||
type FrameworkData = {
|
||||
id: string;
|
||||
name: string;
|
||||
controls: Array<{
|
||||
id: string;
|
||||
sectionTitle: string;
|
||||
name: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type Props<T extends FieldValues = FieldValues> = {
|
||||
control: Control<T>;
|
||||
setValue: UseFormSetValue<T>;
|
||||
name: string;
|
||||
initialControls?: ControlSelection[];
|
||||
initialFrameworkIds?: Set<string>;
|
||||
};
|
||||
|
||||
export function StateOfApplicabilityControlsField<
|
||||
T extends FieldValues = FieldValues,
|
||||
>({ control, setValue, name, initialControls, initialFrameworkIds }: Props<T>) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
|
||||
// Initialize form value with initialControls
|
||||
useEffect(() => {
|
||||
if (initialControls && initialControls.length > 0) {
|
||||
setValue(name as Path<T>, initialControls as PathValue<T, Path<T>>);
|
||||
}
|
||||
}, [initialControls, setValue, name]);
|
||||
|
||||
// Initialize framework selection from initialFrameworkIds
|
||||
const [selectedFrameworkIds, setSelectedFrameworkIds] = useState<
|
||||
Set<string>
|
||||
>(initialFrameworkIds || new Set());
|
||||
const [expandedFrameworks, setExpandedFrameworks] = useState<Set<string>>(
|
||||
initialFrameworkIds || new Set(),
|
||||
);
|
||||
const [frameworkDataMap, setFrameworkDataMap] = useState<
|
||||
Map<string, FrameworkData>
|
||||
>(new Map());
|
||||
const [newFrameworkId, setNewFrameworkId] = useState<string>("");
|
||||
|
||||
// Update framework selection when initialFrameworkIds changes
|
||||
useEffect(() => {
|
||||
if (initialFrameworkIds) {
|
||||
setSelectedFrameworkIds(initialFrameworkIds);
|
||||
setExpandedFrameworks(initialFrameworkIds);
|
||||
}
|
||||
}, [initialFrameworkIds]);
|
||||
|
||||
const addFramework = (frameworkId: string) => {
|
||||
if (!frameworkId || selectedFrameworkIds.has(frameworkId)) return;
|
||||
setSelectedFrameworkIds(
|
||||
new Set([...selectedFrameworkIds, frameworkId]),
|
||||
);
|
||||
setExpandedFrameworks(new Set([...expandedFrameworks, frameworkId]));
|
||||
setNewFrameworkId("");
|
||||
};
|
||||
|
||||
const removeFramework = (frameworkId: string) => {
|
||||
const newSet = new Set(selectedFrameworkIds);
|
||||
newSet.delete(frameworkId);
|
||||
setSelectedFrameworkIds(newSet);
|
||||
|
||||
const newExpanded = new Set(expandedFrameworks);
|
||||
newExpanded.delete(frameworkId);
|
||||
setExpandedFrameworks(newExpanded);
|
||||
|
||||
const newMap = new Map(frameworkDataMap);
|
||||
newMap.delete(frameworkId);
|
||||
setFrameworkDataMap(newMap);
|
||||
};
|
||||
|
||||
const toggleFramework = (frameworkId: string) => {
|
||||
const newExpanded = new Set(expandedFrameworks);
|
||||
if (newExpanded.has(frameworkId)) {
|
||||
newExpanded.delete(frameworkId);
|
||||
} else {
|
||||
newExpanded.add(frameworkId);
|
||||
}
|
||||
setExpandedFrameworks(newExpanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-txt-primary mb-4">
|
||||
{__("Select Controls")}
|
||||
</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Field label={__("Add Framework")}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Select
|
||||
variant="editor"
|
||||
disabled
|
||||
placeholder={__("Loading...")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FrameworkSelect
|
||||
organizationId={organizationId}
|
||||
selectedFrameworkIds={selectedFrameworkIds}
|
||||
value={newFrameworkId}
|
||||
onValueChange={setNewFrameworkId}
|
||||
onAdd={addFramework}
|
||||
/>
|
||||
</Suspense>
|
||||
</Field>
|
||||
|
||||
{Array.from(selectedFrameworkIds).map((frameworkId) => (
|
||||
<Suspense
|
||||
key={frameworkId}
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FrameworkSection
|
||||
frameworkId={frameworkId}
|
||||
isExpanded={expandedFrameworks.has(frameworkId)}
|
||||
onToggle={() => toggleFramework(frameworkId)}
|
||||
onRemove={() => removeFramework(frameworkId)}
|
||||
control={control}
|
||||
name={name}
|
||||
frameworkDataMap={frameworkDataMap}
|
||||
setFrameworkDataMap={setFrameworkDataMap}
|
||||
/>
|
||||
</Suspense>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FrameworkSelect({
|
||||
organizationId,
|
||||
selectedFrameworkIds,
|
||||
value,
|
||||
onValueChange,
|
||||
onAdd,
|
||||
}: {
|
||||
organizationId: string;
|
||||
selectedFrameworkIds: Set<string>;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
onAdd: (frameworkId: string) => void;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const data =
|
||||
useLazyLoadQuery<StateOfApplicabilityControlsFieldFrameworksQuery>(
|
||||
frameworksQuery,
|
||||
{ organizationId },
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
const frameworks: Array<{ id: string; name: string }> =
|
||||
(data?.organization &&
|
||||
"frameworks" in data.organization &&
|
||||
data.organization.frameworks?.edges
|
||||
?.map((edge) => edge.node)
|
||||
.filter(
|
||||
(node): node is NonNullable<typeof node> => node !== null,
|
||||
)) ||
|
||||
[];
|
||||
|
||||
const availableFrameworks = frameworks.filter(
|
||||
(framework: { id: string; name: string }) =>
|
||||
!selectedFrameworkIds.has(framework.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
variant="editor"
|
||||
placeholder={__("Select a framework")}
|
||||
onValueChange={onValueChange}
|
||||
value={value}
|
||||
className="flex-1"
|
||||
>
|
||||
{availableFrameworks.map(
|
||||
(framework: { id: string; name: string }) => (
|
||||
<Option key={framework.id} value={framework.id}>
|
||||
{framework.name}
|
||||
</Option>
|
||||
),
|
||||
)}
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
icon={IconPlusLarge}
|
||||
onClick={() => onAdd(value)}
|
||||
disabled={!value || selectedFrameworkIds.has(value)}
|
||||
>
|
||||
{__("Add")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FrameworkSection<T extends FieldValues = FieldValues>({
|
||||
frameworkId,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onRemove,
|
||||
control,
|
||||
name,
|
||||
frameworkDataMap,
|
||||
setFrameworkDataMap,
|
||||
}: {
|
||||
frameworkId: string;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onRemove: () => void;
|
||||
control: Control<T>;
|
||||
name: string;
|
||||
frameworkDataMap: Map<string, FrameworkData>;
|
||||
setFrameworkDataMap: React.Dispatch<
|
||||
React.SetStateAction<Map<string, FrameworkData>>
|
||||
>;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const data =
|
||||
useLazyLoadQuery<StateOfApplicabilityControlsFieldFrameworkControlsQuery>(
|
||||
frameworkControlsQuery,
|
||||
{ frameworkId },
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
|
||||
const framework =
|
||||
data?.framework && "controls" in data.framework ? data.framework : null;
|
||||
const frameworkName: string =
|
||||
framework && "name" in framework && typeof framework.name === "string"
|
||||
? framework.name
|
||||
: "";
|
||||
const controls: Array<{ id: string; sectionTitle: string; name: string }> =
|
||||
useMemo(
|
||||
() =>
|
||||
framework?.controls?.edges
|
||||
?.map((edge) => edge.node)
|
||||
.filter(
|
||||
(node): node is NonNullable<typeof node> =>
|
||||
node !== null,
|
||||
) ?? [],
|
||||
[framework],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (framework && !frameworkDataMap.has(frameworkId)) {
|
||||
setFrameworkDataMap((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(frameworkId, {
|
||||
id: frameworkId,
|
||||
name: frameworkName,
|
||||
controls,
|
||||
});
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
}, [
|
||||
framework,
|
||||
frameworkId,
|
||||
frameworkName,
|
||||
controls,
|
||||
frameworkDataMap,
|
||||
setFrameworkDataMap,
|
||||
]);
|
||||
|
||||
const cachedData = frameworkDataMap.get(frameworkId);
|
||||
const displayName: string = cachedData?.name || frameworkName;
|
||||
const displayControls: Array<{
|
||||
id: string;
|
||||
sectionTitle: string;
|
||||
name: string;
|
||||
}> = cachedData?.controls || controls;
|
||||
|
||||
return (
|
||||
<div className="border border-border-low rounded-lg">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 flex-1 text-left hover:bg-subtle -m-4 p-4 rounded-lg"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<IconChevronUp
|
||||
size={16}
|
||||
className="text-txt-tertiary"
|
||||
/>
|
||||
) : (
|
||||
<IconChevronDown
|
||||
size={16}
|
||||
className="text-txt-tertiary"
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium text-txt-primary">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="text-sm text-txt-tertiary">
|
||||
({displayControls.length} {__("controls")})
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
icon={IconTrashCan}
|
||||
onClick={onRemove}
|
||||
className="ml-2"
|
||||
>
|
||||
{__("Remove")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border-low">
|
||||
<Controller
|
||||
control={control}
|
||||
name={name as Path<T>}
|
||||
render={({ field }) => {
|
||||
const selectedControls: ControlSelection[] = (
|
||||
Array.isArray(field.value) ? field.value : []
|
||||
) as ControlSelection[];
|
||||
const selectedControlIds = new Set(
|
||||
selectedControls.map(
|
||||
(c: ControlSelection) => c.controlId,
|
||||
),
|
||||
);
|
||||
|
||||
const toggleControl = (controlId: string) => {
|
||||
const isSelected =
|
||||
selectedControlIds.has(controlId);
|
||||
if (isSelected) {
|
||||
field.onChange(
|
||||
selectedControls.filter(
|
||||
(c: ControlSelection) =>
|
||||
c.controlId !== controlId,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
field.onChange([
|
||||
...selectedControls,
|
||||
{
|
||||
controlId,
|
||||
state: "IMPLEMENTED" as const,
|
||||
exclusionJustification: undefined,
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const updateControlState = (
|
||||
controlId: string,
|
||||
state:
|
||||
| "EXCLUDED"
|
||||
| "IMPLEMENTED"
|
||||
| "NOT_IMPLEMENTED",
|
||||
) => {
|
||||
field.onChange(
|
||||
selectedControls.map(
|
||||
(c: ControlSelection) =>
|
||||
c.controlId === controlId
|
||||
? { ...c, state }
|
||||
: c,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const updateJustification = (
|
||||
controlId: string,
|
||||
exclusionJustification: string,
|
||||
) => {
|
||||
field.onChange(
|
||||
selectedControls.map(
|
||||
(c: ControlSelection) =>
|
||||
c.controlId === controlId
|
||||
? {
|
||||
...c,
|
||||
exclusionJustification,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const getControlState = (controlId: string) => {
|
||||
const selected = selectedControls.find(
|
||||
(c: ControlSelection) =>
|
||||
c.controlId === controlId,
|
||||
);
|
||||
return selected?.state || "IMPLEMENTED";
|
||||
};
|
||||
|
||||
const getExclusionJustification = (
|
||||
controlId: string,
|
||||
) => {
|
||||
const selected = selectedControls.find(
|
||||
(c: ControlSelection) =>
|
||||
c.controlId === controlId,
|
||||
);
|
||||
return selected?.exclusionJustification || "";
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
const newSelectedControls: ControlSelection[] =
|
||||
[...selectedControls];
|
||||
displayControls.forEach((ctrl) => {
|
||||
if (!selectedControlIds.has(ctrl.id)) {
|
||||
newSelectedControls.push({
|
||||
controlId: ctrl.id,
|
||||
state: "IMPLEMENTED" as const,
|
||||
exclusionJustification: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
field.onChange(newSelectedControls);
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
const controlIdsToRemove = new Set(
|
||||
displayControls.map((ctrl) => ctrl.id),
|
||||
);
|
||||
const newSelectedControls =
|
||||
selectedControls.filter(
|
||||
(c: ControlSelection) =>
|
||||
!controlIdsToRemove.has(
|
||||
c.controlId,
|
||||
),
|
||||
);
|
||||
field.onChange(newSelectedControls);
|
||||
};
|
||||
|
||||
const allSelected =
|
||||
displayControls.length > 0 &&
|
||||
displayControls.every((ctrl) =>
|
||||
selectedControlIds.has(ctrl.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{displayControls.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="quaternary"
|
||||
onClick={
|
||||
allSelected
|
||||
? deselectAll
|
||||
: selectAll
|
||||
}
|
||||
className="text-xs h-7 min-h-7"
|
||||
>
|
||||
{allSelected
|
||||
? __("Deselect All")
|
||||
: __("Select All")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="border border-border-low rounded-lg max-h-96 overflow-y-auto">
|
||||
{displayControls.length === 0 ? (
|
||||
<div className="p-4 text-center text-txt-tertiary">
|
||||
{__(
|
||||
"No controls found in this framework",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-low">
|
||||
{displayControls.map((ctrl) => {
|
||||
const isSelected =
|
||||
selectedControlIds.has(
|
||||
ctrl.id,
|
||||
);
|
||||
const state =
|
||||
getControlState(
|
||||
ctrl.id,
|
||||
);
|
||||
const exclusionJustification =
|
||||
getExclusionJustification(
|
||||
ctrl.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ctrl.id}
|
||||
className="p-4 space-y-3"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={
|
||||
isSelected
|
||||
}
|
||||
onChange={() =>
|
||||
toggleControl(
|
||||
ctrl.id,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">
|
||||
{
|
||||
ctrl.sectionTitle
|
||||
}
|
||||
:{" "}
|
||||
{
|
||||
ctrl.name
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelected && (
|
||||
<div className="ml-7 space-y-2">
|
||||
<Field
|
||||
label={__(
|
||||
"State",
|
||||
)}
|
||||
>
|
||||
<Select
|
||||
variant="editor"
|
||||
value={
|
||||
state
|
||||
}
|
||||
onValueChange={(
|
||||
value,
|
||||
) =>
|
||||
updateControlState(
|
||||
ctrl.id,
|
||||
value as
|
||||
| "EXCLUDED"
|
||||
| "IMPLEMENTED"
|
||||
| "NOT_IMPLEMENTED",
|
||||
)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<Option value="IMPLEMENTED">
|
||||
{__(
|
||||
"Implemented",
|
||||
)}
|
||||
</Option>
|
||||
<Option value="NOT_IMPLEMENTED">
|
||||
{__(
|
||||
"Not Implemented",
|
||||
)}
|
||||
</Option>
|
||||
<Option value="EXCLUDED">
|
||||
{__(
|
||||
"Excluded",
|
||||
)}
|
||||
</Option>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{state ===
|
||||
"EXCLUDED" ||
|
||||
(state ===
|
||||
"NOT_IMPLEMENTED" && (
|
||||
<Field
|
||||
label={__(
|
||||
"Justification",
|
||||
)}
|
||||
>
|
||||
<Textarea
|
||||
value={
|
||||
exclusionJustification
|
||||
}
|
||||
onChange={(
|
||||
e,
|
||||
) =>
|
||||
updateJustification(
|
||||
ctrl.id,
|
||||
e
|
||||
.target
|
||||
.value,
|
||||
)
|
||||
}
|
||||
placeholder={__(
|
||||
"Reason for exclusion",
|
||||
)}
|
||||
autogrow
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export function VendorsMultiSelectField<T extends FieldValues = FieldValues>({
|
||||
}
|
||||
|
||||
function VendorsMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
props: Pick<Props<T>, "organizationId" | "control" | "name" | "disabled" | "selectedVendors">
|
||||
props: Pick<Props<T>, "organizationId" | "control" | "name" | "disabled" | "selectedVendors">,
|
||||
) {
|
||||
const { __ } = useTranslate();
|
||||
const { name, organizationId, control, selectedVendors = [] } = props;
|
||||
@@ -54,7 +54,7 @@ function VendorsMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
|
||||
const allVendors = [...vendors];
|
||||
if (props.disabled) {
|
||||
selectedVendors.forEach(selectedVendor => {
|
||||
selectedVendors.forEach((selectedVendor) => {
|
||||
if (!allVendors.find(v => v.id === selectedVendor.id)) {
|
||||
allVendors.push(selectedVendor);
|
||||
}
|
||||
@@ -98,7 +98,7 @@ function VendorsMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
{availableVendors.map((vendor) => (
|
||||
{availableVendors.map(vendor => (
|
||||
<Option key={vendor.id} value={vendor.id} className="flex gap-2">
|
||||
<Avatar
|
||||
name={vendor.name}
|
||||
@@ -120,7 +120,7 @@ function VendorsMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
|
||||
{selectedVendors.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedVendors.map((vendor) => (
|
||||
{selectedVendors.map(vendor => (
|
||||
<Badge key={vendor.id} variant="neutral" className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={vendor.name}
|
||||
|
||||
@@ -136,7 +136,7 @@ export function LinkedMeasuresCard<Params>(props: Props<Params>) {
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{measures.map((measure) => (
|
||||
{measures.map(measure => (
|
||||
<MeasureRow
|
||||
key={measure.id}
|
||||
measure={measure}
|
||||
|
||||
@@ -44,28 +44,28 @@ export function LinkedMeasureDialog({ children, ...props }: Props) {
|
||||
|
||||
function LinkedMeasuresDialogContent(props: Omit<Props, "children">) {
|
||||
const organizationId = useOrganizationId();
|
||||
const { data, loadNext, hasNext, isLoadingNext } =
|
||||
usePaginatedMeasures(organizationId);
|
||||
const { data, loadNext, hasNext, isLoadingNext }
|
||||
= usePaginatedMeasures(organizationId);
|
||||
const { __ } = useTranslate();
|
||||
const [search, setSearch] = useState("");
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
const measures = useMemo(() => data.measures?.edges?.map((edge) => edge.node) ?? [], [data.measures]);
|
||||
const measures = useMemo(() => data.measures?.edges?.map(edge => edge.node) ?? [], [data.measures]);
|
||||
const linkedIds = useMemo(() => {
|
||||
return new Set(props.linkedMeasures?.map((m) => m.id) ?? []);
|
||||
return new Set(props.linkedMeasures?.map(m => m.id) ?? []);
|
||||
}, [props.linkedMeasures]);
|
||||
|
||||
const filteredMeasures = useMemo(() => {
|
||||
return measures.filter(
|
||||
(measure) =>
|
||||
(category === null || measure.category === category) &&
|
||||
(measure.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
measure.description?.toLowerCase().includes(search.toLowerCase()))
|
||||
measure =>
|
||||
(category === null || measure.category === category)
|
||||
&& (measure.name.toLowerCase().includes(search.toLowerCase())
|
||||
|| measure.description?.toLowerCase().includes(search.toLowerCase())),
|
||||
);
|
||||
}, [measures, search, category]);
|
||||
|
||||
const categories = useMemo(
|
||||
() => Array.from(new Set(measures.map((m) => m.category))),
|
||||
[measures]
|
||||
() => Array.from(new Set(measures.map(m => m.category))),
|
||||
[measures],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -82,7 +82,7 @@ function LinkedMeasuresDialogContent(props: Omit<Props, "children">) {
|
||||
onValueChange={setCategory}
|
||||
className="max-w-[180px]"
|
||||
>
|
||||
{categories.map((category) => (
|
||||
{categories.map(category => (
|
||||
<Option key={category} value={category}>
|
||||
{category}
|
||||
</Option>
|
||||
@@ -90,7 +90,7 @@ function LinkedMeasuresDialogContent(props: Omit<Props, "children">) {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="divide-y divide-border-low">
|
||||
{filteredMeasures.map((measure) => (
|
||||
{filteredMeasures.map(measure => (
|
||||
<MeasureRow
|
||||
key={measure.id}
|
||||
measure={measure}
|
||||
@@ -140,7 +140,9 @@ function MeasureRow(props: RowProps) {
|
||||
asChild
|
||||
>
|
||||
<span>
|
||||
<IconComponent size={16} /> {isLinked ? __("Unlink") : __("Link")}
|
||||
<IconComponent size={16} />
|
||||
{" "}
|
||||
{isLinked ? __("Unlink") : __("Link")}
|
||||
</span>
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
@@ -143,7 +143,7 @@ export function LinkedObligationsCard<Params>(props: Props<Params>) {
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{obligations.map((obligation) => (
|
||||
{obligations.map(obligation => (
|
||||
<ObligationRow
|
||||
key={obligation.id}
|
||||
obligation={obligation}
|
||||
|
||||
@@ -111,19 +111,19 @@ function LinkedObligationsDialogContent(props: Omit<Props, "children">) {
|
||||
const { __ } = useTranslate();
|
||||
const [search, setSearch] = useState("");
|
||||
const obligations = useMemo(
|
||||
() => data.obligations?.edges?.map((edge) => edge.node) ?? [],
|
||||
() => data.obligations?.edges?.map(edge => edge.node) ?? [],
|
||||
[data.obligations],
|
||||
);
|
||||
const linkedIds = useMemo(() => {
|
||||
return new Set(props.linkedObligations?.map((o) => o.id) ?? []);
|
||||
return new Set(props.linkedObligations?.map(o => o.id) ?? []);
|
||||
}, [props.linkedObligations]);
|
||||
|
||||
const filteredObligations = useMemo(() => {
|
||||
return obligations.filter(
|
||||
(obligation) =>
|
||||
obligation.area?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
obligation.source?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
obligation.owner?.fullName
|
||||
obligation =>
|
||||
obligation.area?.toLowerCase().includes(search.toLowerCase())
|
||||
|| obligation.source?.toLowerCase().includes(search.toLowerCase())
|
||||
|| obligation.owner?.fullName
|
||||
?.toLowerCase()
|
||||
.includes(search.toLowerCase()),
|
||||
);
|
||||
@@ -139,7 +139,7 @@ function LinkedObligationsDialogContent(props: Omit<Props, "children">) {
|
||||
/>
|
||||
</div>
|
||||
<div className="divide-y divide-border-low">
|
||||
{filteredObligations.map((obligation) => (
|
||||
{filteredObligations.map(obligation => (
|
||||
<ObligationRow
|
||||
key={obligation.id}
|
||||
obligation={obligation}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -32,14 +32,9 @@ export function DeleteOrganizationDialog({
|
||||
const handleConfirm = () => {
|
||||
if (inputValue === organizationName) {
|
||||
onConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDeleting) {
|
||||
setInputValue("");
|
||||
}
|
||||
}, [isDeleting]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -52,7 +47,7 @@ export function DeleteOrganizationDialog({
|
||||
<p className="text-txt-secondary text-sm">
|
||||
{sprintf(
|
||||
__("This will permanently delete the organization %s and all its data."),
|
||||
organizationName
|
||||
organizationName,
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -62,12 +57,12 @@ export function DeleteOrganizationDialog({
|
||||
|
||||
<Field
|
||||
label={sprintf(
|
||||
__('To confirm deletion, type "%s" below:'),
|
||||
organizationName
|
||||
__("To confirm deletion, type \"%s\" below:"),
|
||||
organizationName,
|
||||
)}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
placeholder={organizationName}
|
||||
disabled={isDeleting}
|
||||
autoComplete="off"
|
||||
|
||||
@@ -27,7 +27,7 @@ export function SlackConnections({
|
||||
];
|
||||
|
||||
const slackConnections = slackConnectionDefinitions.map((def) => {
|
||||
const connected = connectedSlackConnections.find((c) => c.id);
|
||||
const connected = connectedSlackConnections.find(c => c.id);
|
||||
return {
|
||||
...def,
|
||||
createdAt: connected?.createdAt,
|
||||
@@ -49,7 +49,7 @@ export function SlackConnections({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{slackConnections.map((slackConnection) => (
|
||||
{slackConnections.map(slackConnection => (
|
||||
<Card
|
||||
key={slackConnection.id}
|
||||
padded
|
||||
@@ -61,37 +61,41 @@ export function SlackConnections({
|
||||
<div className="mr-auto">
|
||||
<h3 className="text-base font-semibold">{slackConnection.name}</h3>
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{slackConnection.createdAt ? (
|
||||
<>
|
||||
{sprintf(
|
||||
__("Connected on %s"),
|
||||
dateTimeFormat(slackConnection.createdAt),
|
||||
)}
|
||||
{slackConnection.channel && (
|
||||
{slackConnection.createdAt
|
||||
? (
|
||||
<>
|
||||
{" • "}
|
||||
{sprintf(__("Channel: %s"), slackConnection.channel)}
|
||||
{sprintf(
|
||||
__("Connected on %s"),
|
||||
dateTimeFormat(slackConnection.createdAt),
|
||||
)}
|
||||
{slackConnection.channel && (
|
||||
<>
|
||||
{" • "}
|
||||
{sprintf(__("Channel: %s"), slackConnection.channel)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
slackConnection.description
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
slackConnection.description
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{slackConnection.createdAt ? (
|
||||
<div>
|
||||
<Badge variant="success" size="md">
|
||||
{__("Connected")}
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
canUpdate && (
|
||||
<Button variant="secondary" asChild>
|
||||
<a href={getUrl(slackConnection.id)}>{__("Connect")}</a>
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{slackConnection.createdAt
|
||||
? (
|
||||
<div>
|
||||
<Badge variant="success" size="md">
|
||||
{__("Connected")}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
canUpdate && (
|
||||
<Button variant="secondary" asChild>
|
||||
<a href={getUrl(slackConnection.id)}>{__("Connect")}</a>
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function LinkedRisksCard<Params>(props: Props<Params>) {
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{props.risks.map((risk) => (
|
||||
{props.risks.map(risk => (
|
||||
<RiskRow
|
||||
key={risk.id}
|
||||
risk={risk}
|
||||
|
||||
@@ -74,24 +74,24 @@ function LinkedRisksDialogContent(props: Omit<Props, "children">) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
const risks = useMemo(
|
||||
() => data.organization?.risks?.edges?.map((edge) => edge.node) ?? [],
|
||||
() => data.organization?.risks?.edges?.map(edge => edge.node) ?? [],
|
||||
[data.organization?.risks],
|
||||
);
|
||||
const linkedIds = useMemo(() => {
|
||||
return new Set(props.linkedRisks?.map((r) => r.id) ?? []);
|
||||
return new Set(props.linkedRisks?.map(r => r.id) ?? []);
|
||||
}, [props.linkedRisks]);
|
||||
|
||||
const filteredRisks = useMemo(() => {
|
||||
return risks.filter(
|
||||
(risk) =>
|
||||
(category === null || risk.category === category) &&
|
||||
(risk.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
risk.description?.toLowerCase().includes(search.toLowerCase())),
|
||||
risk =>
|
||||
(category === null || risk.category === category)
|
||||
&& (risk.name.toLowerCase().includes(search.toLowerCase())
|
||||
|| risk.description?.toLowerCase().includes(search.toLowerCase())),
|
||||
);
|
||||
}, [risks, search, category]);
|
||||
|
||||
const categories = useMemo(
|
||||
() => Array.from(new Set(risks.map((r) => r.category))),
|
||||
() => Array.from(new Set(risks.map(r => r.category))),
|
||||
[risks],
|
||||
);
|
||||
|
||||
@@ -109,7 +109,7 @@ function LinkedRisksDialogContent(props: Omit<Props, "children">) {
|
||||
onValueChange={setCategory}
|
||||
className="max-w-[180px]"
|
||||
>
|
||||
{categories.map((category) => (
|
||||
{categories.map(category => (
|
||||
<Option key={category} value={category}>
|
||||
{category}
|
||||
</Option>
|
||||
@@ -117,7 +117,7 @@ function LinkedRisksDialogContent(props: Omit<Props, "children">) {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="divide-y divide-border-low">
|
||||
{filteredRisks.map((risk) => (
|
||||
{filteredRisks.map(risk => (
|
||||
<RiskRow
|
||||
key={risk.id}
|
||||
risk={risk}
|
||||
@@ -167,7 +167,9 @@ function RiskRow(props: RowProps) {
|
||||
asChild
|
||||
>
|
||||
<span>
|
||||
<IconComponent size={16} /> {isLinked ? __("Unlink") : __("Link")}
|
||||
<IconComponent size={16} />
|
||||
{" "}
|
||||
{isLinked ? __("Unlink") : __("Link")}
|
||||
</span>
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
@@ -136,7 +136,7 @@ export function LinkedSnapshotsCard<Params>(props: Props<Params>) {
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{snapshots.map((snapshot) => (
|
||||
{snapshots.map(snapshot => (
|
||||
<SnapshotRow
|
||||
key={snapshot.id}
|
||||
snapshot={snapshot}
|
||||
|
||||
@@ -101,15 +101,15 @@ function LinkedSnapshotsDialogContent(props: Omit<Props, "children">) {
|
||||
const { __ } = useTranslate();
|
||||
const [search, setSearch] = useState("");
|
||||
const snapshots = useMemo(
|
||||
() => data.snapshots?.edges?.map((edge) => edge.node) ?? [],
|
||||
() => data.snapshots?.edges?.map(edge => edge.node) ?? [],
|
||||
[data.snapshots],
|
||||
);
|
||||
const linkedIds = useMemo(() => {
|
||||
return new Set(props.linkedSnapshots?.map((s) => s.id) ?? []);
|
||||
return new Set(props.linkedSnapshots?.map(s => s.id) ?? []);
|
||||
}, [props.linkedSnapshots]);
|
||||
|
||||
const filteredSnapshots = useMemo(() => {
|
||||
return snapshots.filter((snapshot) =>
|
||||
return snapshots.filter(snapshot =>
|
||||
snapshot.name.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
}, [snapshots, search]);
|
||||
@@ -124,7 +124,7 @@ function LinkedSnapshotsDialogContent(props: Omit<Props, "children">) {
|
||||
/>
|
||||
</div>
|
||||
<div className="divide-y divide-border-low">
|
||||
{filteredSnapshots.map((snapshot) => (
|
||||
{filteredSnapshots.map(snapshot => (
|
||||
<SnapshotRow
|
||||
key={snapshot.id}
|
||||
snapshot={snapshot}
|
||||
@@ -187,7 +187,9 @@ function SnapshotRow(props: RowProps) {
|
||||
asChild
|
||||
>
|
||||
<span>
|
||||
<IconComponent size={16} /> {isLinked ? __("Unlink") : __("Link")}
|
||||
<IconComponent size={16} />
|
||||
{" "}
|
||||
{isLinked ? __("Unlink") : __("Link")}
|
||||
</span>
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { graphql } from "relay-runtime";
|
||||
import {
|
||||
Card,
|
||||
IconPlusLarge,
|
||||
Button,
|
||||
Tr,
|
||||
Td,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Th,
|
||||
IconChevronDown,
|
||||
IconTrashCan,
|
||||
TrButton,
|
||||
Badge,
|
||||
Card,
|
||||
IconPlusLarge,
|
||||
Button,
|
||||
Tr,
|
||||
Td,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Th,
|
||||
IconChevronDown,
|
||||
IconTrashCan,
|
||||
TrButton,
|
||||
Badge,
|
||||
} from "@probo/ui";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import type { LinkedStatesOfApplicabilityCardFragment$key } from "/__generated__/core/LinkedStatesOfApplicabilityCardFragment.graphql";
|
||||
@@ -38,257 +38,257 @@ const linkedStateOfApplicabilityFragment = graphql`
|
||||
`;
|
||||
|
||||
type AttachMutation<Params> = (p: {
|
||||
variables: {
|
||||
input: {
|
||||
stateOfApplicabilityId: string;
|
||||
applicability: boolean;
|
||||
justification: string | null;
|
||||
} & Params;
|
||||
connections: string[];
|
||||
};
|
||||
variables: {
|
||||
input: {
|
||||
stateOfApplicabilityId: string;
|
||||
applicability: boolean;
|
||||
justification: string | null;
|
||||
} & Params;
|
||||
connections: string[];
|
||||
};
|
||||
}) => void;
|
||||
|
||||
type DetachMutation = (p: {
|
||||
variables: {
|
||||
input: {
|
||||
stateOfApplicabilityId: string;
|
||||
controlId: string;
|
||||
};
|
||||
connections: string[];
|
||||
variables: {
|
||||
input: {
|
||||
stateOfApplicabilityId: string;
|
||||
controlId: string;
|
||||
};
|
||||
connections: string[];
|
||||
};
|
||||
}) => void;
|
||||
|
||||
type Props<Params> = {
|
||||
statesOfApplicability: readonly (LinkedStatesOfApplicabilityCardFragment$key & {
|
||||
id: string;
|
||||
})[];
|
||||
params: Params;
|
||||
disabled?: boolean;
|
||||
connectionId: string;
|
||||
onAttach: AttachMutation<Params>;
|
||||
onDetach: DetachMutation;
|
||||
variant?: "card" | "table";
|
||||
readOnly?: boolean;
|
||||
statesOfApplicability: readonly (LinkedStatesOfApplicabilityCardFragment$key & {
|
||||
id: string;
|
||||
})[];
|
||||
params: Params;
|
||||
disabled?: boolean;
|
||||
connectionId: string;
|
||||
onAttach: AttachMutation<Params>;
|
||||
onDetach: DetachMutation;
|
||||
variant?: "card" | "table";
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export function LinkedStatesOfApplicabilityCard<Params>(props: Props<Params>) {
|
||||
const { __ } = useTranslate();
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const [limit, setLimit] = useState<number | null>(
|
||||
props.variant === "card" ? 4 : null,
|
||||
);
|
||||
const [limit, setLimit] = useState<number | null>(
|
||||
props.variant === "card" ? 4 : null,
|
||||
);
|
||||
|
||||
const [linkedInfo, setLinkedInfo] = useState<
|
||||
{ stateOfApplicabilityId: string; controlId: string }[]
|
||||
>([]);
|
||||
const [linkedInfo, setLinkedInfo] = useState<
|
||||
{ stateOfApplicabilityId: string; controlId: string }[]
|
||||
>([]);
|
||||
|
||||
const statesOfApplicability = useMemo(() => {
|
||||
return limit
|
||||
? props.statesOfApplicability.slice(0, limit)
|
||||
: props.statesOfApplicability;
|
||||
}, [props.statesOfApplicability, limit]);
|
||||
const statesOfApplicability = useMemo(() => {
|
||||
return limit
|
||||
? props.statesOfApplicability.slice(0, limit)
|
||||
: props.statesOfApplicability;
|
||||
}, [props.statesOfApplicability, limit]);
|
||||
|
||||
const showMoreButton =
|
||||
limit !== null && props.statesOfApplicability.length > limit;
|
||||
const variant = props.variant ?? "table";
|
||||
const showMoreButton
|
||||
= limit !== null && props.statesOfApplicability.length > limit;
|
||||
const variant = props.variant ?? "table";
|
||||
|
||||
const linkedData = linkedInfo;
|
||||
const linkedData = linkedInfo;
|
||||
|
||||
const onAttach = (
|
||||
stateOfApplicabilityId: string,
|
||||
applicability: boolean,
|
||||
justification: string | null,
|
||||
) => {
|
||||
props.onAttach({
|
||||
variables: {
|
||||
input: {
|
||||
stateOfApplicabilityId,
|
||||
applicability,
|
||||
justification,
|
||||
...props.params,
|
||||
},
|
||||
connections: [props.connectionId],
|
||||
},
|
||||
});
|
||||
};
|
||||
const onAttach = (
|
||||
stateOfApplicabilityId: string,
|
||||
applicability: boolean,
|
||||
justification: string | null,
|
||||
) => {
|
||||
props.onAttach({
|
||||
variables: {
|
||||
input: {
|
||||
stateOfApplicabilityId,
|
||||
applicability,
|
||||
justification,
|
||||
...props.params,
|
||||
},
|
||||
connections: [props.connectionId],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onDetach = (stateOfApplicabilityId: string, controlId: string) => {
|
||||
props.onDetach({
|
||||
variables: {
|
||||
input: {
|
||||
stateOfApplicabilityId,
|
||||
controlId,
|
||||
},
|
||||
connections: [props.connectionId],
|
||||
},
|
||||
});
|
||||
};
|
||||
const onDetach = (stateOfApplicabilityId: string, controlId: string) => {
|
||||
props.onDetach({
|
||||
variables: {
|
||||
input: {
|
||||
stateOfApplicabilityId,
|
||||
controlId,
|
||||
},
|
||||
connections: [props.connectionId],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const Wrapper = variant === "card" ? Card : "div";
|
||||
const Wrapper = variant === "card" ? Card : "div";
|
||||
|
||||
return (
|
||||
<Wrapper padded className="space-y-[10px]">
|
||||
{props.statesOfApplicability.map((soa, idx) => (
|
||||
<LinkedInfoExtractor
|
||||
key={idx}
|
||||
fragment={soa}
|
||||
onExtracted={(info) => {
|
||||
setLinkedInfo((prev) => {
|
||||
const exists = prev.some(
|
||||
(p) =>
|
||||
p.stateOfApplicabilityId ===
|
||||
info.stateOfApplicabilityId &&
|
||||
p.controlId === info.controlId,
|
||||
);
|
||||
return exists ? prev : [...prev, info];
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{variant === "card" && (
|
||||
<div className="flex justify-between">
|
||||
<div className="text-lg font-semibold">
|
||||
{__("States of Applicability")}
|
||||
</div>
|
||||
{!props.readOnly && (
|
||||
<LinkedStatesOfApplicabilityDialog
|
||||
connectionId={props.connectionId}
|
||||
disabled={props.disabled}
|
||||
linkedStatesOfApplicability={linkedData}
|
||||
onLink={onAttach}
|
||||
onUnlink={onDetach}
|
||||
>
|
||||
<Button variant="tertiary" icon={IconPlusLarge}>
|
||||
{__("Link state of applicability")}
|
||||
</Button>
|
||||
</LinkedStatesOfApplicabilityDialog>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Table className={clsx(variant === "card" && "bg-invert")}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{__("Name")}</Th>
|
||||
<Th>{__("Applicability")}</Th>
|
||||
<Th>{__("Justification")}</Th>
|
||||
{!props.readOnly && <Th></Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{statesOfApplicability.length === 0 && (
|
||||
<Tr>
|
||||
<Td
|
||||
colSpan={props.readOnly ? 3 : 4}
|
||||
className="text-center text-txt-secondary"
|
||||
>
|
||||
{__("No states of applicability linked")}
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{statesOfApplicability.map((soa) => (
|
||||
<StateOfApplicabilityRow
|
||||
key={soa.id}
|
||||
stateOfApplicability={soa}
|
||||
onClick={onDetach}
|
||||
readOnly={props.readOnly}
|
||||
/>
|
||||
))}
|
||||
{variant === "table" && !props.readOnly && (
|
||||
<LinkedStatesOfApplicabilityDialog
|
||||
connectionId={props.connectionId}
|
||||
disabled={props.disabled}
|
||||
linkedStatesOfApplicability={linkedData}
|
||||
onLink={onAttach}
|
||||
onUnlink={onDetach}
|
||||
>
|
||||
<TrButton colspan={4} icon={IconPlusLarge}>
|
||||
{__("Link state of applicability")}
|
||||
</TrButton>
|
||||
</LinkedStatesOfApplicabilityDialog>
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{showMoreButton && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
icon={IconChevronDown}
|
||||
onClick={() => setLimit(null)}
|
||||
>
|
||||
{sprintf(
|
||||
__("Show %d more"),
|
||||
props.statesOfApplicability.length - limit!,
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
return (
|
||||
<Wrapper padded className="space-y-[10px]">
|
||||
{props.statesOfApplicability.map((soa, idx) => (
|
||||
<LinkedInfoExtractor
|
||||
key={idx}
|
||||
fragment={soa}
|
||||
onExtracted={(info) => {
|
||||
setLinkedInfo((prev) => {
|
||||
const exists = prev.some(
|
||||
p =>
|
||||
p.stateOfApplicabilityId
|
||||
=== info.stateOfApplicabilityId
|
||||
&& p.controlId === info.controlId,
|
||||
);
|
||||
return exists ? prev : [...prev, info];
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{variant === "card" && (
|
||||
<div className="flex justify-between">
|
||||
<div className="text-lg font-semibold">
|
||||
{__("States of Applicability")}
|
||||
</div>
|
||||
{!props.readOnly && (
|
||||
<LinkedStatesOfApplicabilityDialog
|
||||
connectionId={props.connectionId}
|
||||
disabled={props.disabled}
|
||||
linkedStatesOfApplicability={linkedData}
|
||||
onLink={onAttach}
|
||||
onUnlink={onDetach}
|
||||
>
|
||||
<Button variant="tertiary" icon={IconPlusLarge}>
|
||||
{__("Link state of applicability")}
|
||||
</Button>
|
||||
</LinkedStatesOfApplicabilityDialog>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Table className={clsx(variant === "card" && "bg-invert")}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{__("Name")}</Th>
|
||||
<Th>{__("Applicability")}</Th>
|
||||
<Th>{__("Justification")}</Th>
|
||||
{!props.readOnly && <Th></Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{statesOfApplicability.length === 0 && (
|
||||
<Tr>
|
||||
<Td
|
||||
colSpan={props.readOnly ? 3 : 4}
|
||||
className="text-center text-txt-secondary"
|
||||
>
|
||||
{__("No states of applicability linked")}
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{statesOfApplicability.map(soa => (
|
||||
<StateOfApplicabilityRow
|
||||
key={soa.id}
|
||||
stateOfApplicability={soa}
|
||||
onClick={onDetach}
|
||||
readOnly={props.readOnly}
|
||||
/>
|
||||
))}
|
||||
{variant === "table" && !props.readOnly && (
|
||||
<LinkedStatesOfApplicabilityDialog
|
||||
connectionId={props.connectionId}
|
||||
disabled={props.disabled}
|
||||
linkedStatesOfApplicability={linkedData}
|
||||
onLink={onAttach}
|
||||
onUnlink={onDetach}
|
||||
>
|
||||
<TrButton colspan={4} icon={IconPlusLarge}>
|
||||
{__("Link state of applicability")}
|
||||
</TrButton>
|
||||
</LinkedStatesOfApplicabilityDialog>
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{showMoreButton && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
icon={IconChevronDown}
|
||||
onClick={() => setLimit(null)}
|
||||
>
|
||||
{sprintf(
|
||||
__("Show %d more"),
|
||||
props.statesOfApplicability.length - limit,
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedInfoExtractor(props: {
|
||||
fragment: LinkedStatesOfApplicabilityCardFragment$key;
|
||||
onExtracted: (info: {
|
||||
stateOfApplicabilityId: string;
|
||||
controlId: string;
|
||||
}) => void;
|
||||
fragment: LinkedStatesOfApplicabilityCardFragment$key;
|
||||
onExtracted: (info: {
|
||||
stateOfApplicabilityId: string;
|
||||
controlId: string;
|
||||
}) => void;
|
||||
}) {
|
||||
const data = useFragment(
|
||||
linkedStateOfApplicabilityFragment,
|
||||
props.fragment,
|
||||
);
|
||||
const { fragment, onExtracted } = props;
|
||||
const data = useFragment(
|
||||
linkedStateOfApplicabilityFragment,
|
||||
fragment,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
props.onExtracted({
|
||||
stateOfApplicabilityId: data.stateOfApplicabilityId,
|
||||
controlId: data.controlId,
|
||||
});
|
||||
}, [data.stateOfApplicabilityId, data.controlId, props.onExtracted]);
|
||||
useEffect(() => {
|
||||
onExtracted({
|
||||
stateOfApplicabilityId: data.stateOfApplicabilityId,
|
||||
controlId: data.controlId,
|
||||
});
|
||||
}, [data.stateOfApplicabilityId, data.controlId, onExtracted]);
|
||||
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function StateOfApplicabilityRow(props: {
|
||||
stateOfApplicability: LinkedStatesOfApplicabilityCardFragment$key & {
|
||||
id: string;
|
||||
};
|
||||
onClick: (stateOfApplicabilityId: string, controlId: string) => void;
|
||||
readOnly?: boolean;
|
||||
stateOfApplicability: LinkedStatesOfApplicabilityCardFragment$key & {
|
||||
id: string;
|
||||
};
|
||||
onClick: (stateOfApplicabilityId: string, controlId: string) => void;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const soa = useFragment(
|
||||
linkedStateOfApplicabilityFragment,
|
||||
props.stateOfApplicability,
|
||||
);
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
const soa = useFragment(
|
||||
linkedStateOfApplicabilityFragment,
|
||||
props.stateOfApplicability,
|
||||
);
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
|
||||
return (
|
||||
<Tr
|
||||
to={`/organizations/${organizationId}/states-of-applicability/${soa.stateOfApplicabilityId}`}
|
||||
>
|
||||
<Td>{soa.stateOfApplicability.name}</Td>
|
||||
<Td>
|
||||
<Badge variant={soa.applicability ? "success" : "danger"}>
|
||||
{soa.applicability
|
||||
? __("Applicable")
|
||||
: __("Not Applicable")}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>{soa.justification || "-"}</Td>
|
||||
{!props.readOnly && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
props.onClick(
|
||||
soa.stateOfApplicabilityId,
|
||||
soa.controlId,
|
||||
)
|
||||
}
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{__("Unlink")}
|
||||
</Button>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
return (
|
||||
<Tr
|
||||
to={`/organizations/${organizationId}/states-of-applicability/${soa.stateOfApplicabilityId}`}
|
||||
>
|
||||
<Td>{soa.stateOfApplicability.name}</Td>
|
||||
<Td>
|
||||
<Badge variant={soa.applicability ? "success" : "danger"}>
|
||||
{soa.applicability
|
||||
? __("Applicable")
|
||||
: __("Not Applicable")}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>{soa.justification || "-"}</Td>
|
||||
{!props.readOnly && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
props.onClick(
|
||||
soa.stateOfApplicabilityId,
|
||||
soa.controlId,
|
||||
)}
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{__("Unlink")}
|
||||
</Button>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Button,
|
||||
Checkbox,
|
||||
Textarea,
|
||||
Badge,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Button,
|
||||
Checkbox,
|
||||
Textarea,
|
||||
Badge,
|
||||
} from "@probo/ui";
|
||||
import { Suspense, useState, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -33,233 +33,237 @@ const query = graphql`
|
||||
`;
|
||||
|
||||
type LinkedSOAInfo = {
|
||||
stateOfApplicabilityId: string;
|
||||
controlId: string;
|
||||
stateOfApplicabilityId: string;
|
||||
controlId: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
connectionId: string;
|
||||
disabled?: boolean;
|
||||
linkedStatesOfApplicability: readonly LinkedSOAInfo[];
|
||||
onLink: (
|
||||
stateOfApplicabilityId: string,
|
||||
applicability: boolean,
|
||||
justification: string | null,
|
||||
) => void;
|
||||
onUnlink: (stateOfApplicabilityId: string, controlId: string) => void;
|
||||
children: ReactNode;
|
||||
connectionId: string;
|
||||
disabled?: boolean;
|
||||
linkedStatesOfApplicability: readonly LinkedSOAInfo[];
|
||||
onLink: (
|
||||
stateOfApplicabilityId: string,
|
||||
applicability: boolean,
|
||||
justification: string | null,
|
||||
) => void;
|
||||
onUnlink: (stateOfApplicabilityId: string, controlId: string) => void;
|
||||
};
|
||||
|
||||
export function LinkedStatesOfApplicabilityDialog({
|
||||
children,
|
||||
...props
|
||||
children,
|
||||
...props
|
||||
}: Props) {
|
||||
const dialogRef = useRef<{ open: () => void; close: () => void }>(null);
|
||||
const dialogRef = useRef<{ open: () => void; close: () => void }>(null);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
ref={dialogRef}
|
||||
trigger={children}
|
||||
title="Link State of Applicability"
|
||||
>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<LinkedStatesOfApplicabilityDialogContent
|
||||
{...props}
|
||||
onClose={() => dialogRef.current?.close()}
|
||||
/>
|
||||
</Suspense>
|
||||
</Dialog>
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
ref={dialogRef}
|
||||
trigger={children}
|
||||
title="Link State of Applicability"
|
||||
>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<LinkedStatesOfApplicabilityDialogContent
|
||||
{...props}
|
||||
onClose={() => dialogRef.current?.close()}
|
||||
/>
|
||||
</Suspense>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedStatesOfApplicabilityDialogContent(
|
||||
props: Omit<Props, "children"> & { onClose: () => void },
|
||||
props: Omit<Props, "children"> & { onClose: () => void },
|
||||
) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const [selectedSOA, setSelectedSOA] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [applicability, setApplicability] = useState(true);
|
||||
const [justification, setJustification] = useState("");
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const [selectedSOA, setSelectedSOA] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [applicability, setApplicability] = useState(true);
|
||||
const [justification, setJustification] = useState("");
|
||||
|
||||
const data = useLazyLoadQuery<LinkedStatesOfApplicabilityDialogQuery>(
|
||||
query,
|
||||
{
|
||||
organizationId,
|
||||
},
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
const data = useLazyLoadQuery<LinkedStatesOfApplicabilityDialogQuery>(
|
||||
query,
|
||||
{
|
||||
organizationId,
|
||||
},
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
|
||||
const linkedSOAIds = new Set(
|
||||
props.linkedStatesOfApplicability.map(
|
||||
(soa) => soa.stateOfApplicabilityId,
|
||||
),
|
||||
);
|
||||
const linkedSOAMap = new Map(
|
||||
props.linkedStatesOfApplicability.map((soa) => [
|
||||
soa.stateOfApplicabilityId,
|
||||
soa,
|
||||
]),
|
||||
);
|
||||
const statesOfApplicability =
|
||||
data.organization?.statesOfApplicability?.edges.map(
|
||||
(edge) => edge.node,
|
||||
) ?? [];
|
||||
const linkedSOAIds = new Set(
|
||||
props.linkedStatesOfApplicability.map(
|
||||
soa => soa.stateOfApplicabilityId,
|
||||
),
|
||||
);
|
||||
const linkedSOAMap = new Map(
|
||||
props.linkedStatesOfApplicability.map(soa => [
|
||||
soa.stateOfApplicabilityId,
|
||||
soa,
|
||||
]),
|
||||
);
|
||||
const statesOfApplicability
|
||||
= data.organization?.statesOfApplicability?.edges.map(
|
||||
edge => edge.node,
|
||||
) ?? [];
|
||||
|
||||
const handleSelectSOA = (soa: { id: string; name: string }) => {
|
||||
setSelectedSOA(soa);
|
||||
setApplicability(true);
|
||||
setJustification("");
|
||||
};
|
||||
const handleSelectSOA = (soa: { id: string; name: string }) => {
|
||||
setSelectedSOA(soa);
|
||||
setApplicability(true);
|
||||
setJustification("");
|
||||
};
|
||||
|
||||
const handleLink = () => {
|
||||
if (selectedSOA) {
|
||||
props.onLink(
|
||||
selectedSOA.id,
|
||||
applicability,
|
||||
justification.trim() || null,
|
||||
);
|
||||
props.onClose();
|
||||
}
|
||||
};
|
||||
const handleLink = () => {
|
||||
if (selectedSOA) {
|
||||
props.onLink(
|
||||
selectedSOA.id,
|
||||
applicability,
|
||||
justification.trim() || null,
|
||||
);
|
||||
props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlink = (stateOfApplicabilityId: string) => {
|
||||
const linkedSOA = linkedSOAMap.get(stateOfApplicabilityId);
|
||||
if (linkedSOA) {
|
||||
props.onUnlink(
|
||||
linkedSOA.stateOfApplicabilityId,
|
||||
linkedSOA.controlId,
|
||||
);
|
||||
}
|
||||
};
|
||||
const handleUnlink = (stateOfApplicabilityId: string) => {
|
||||
const linkedSOA = linkedSOAMap.get(stateOfApplicabilityId);
|
||||
if (linkedSOA) {
|
||||
props.onUnlink(
|
||||
linkedSOA.stateOfApplicabilityId,
|
||||
linkedSOA.controlId,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogContent padded className="space-y-4">
|
||||
{statesOfApplicability.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="text-txt-secondary text-base mb-2">
|
||||
{__("No states of applicability available")}
|
||||
</div>
|
||||
<div className="text-txt-tertiary text-sm">
|
||||
{__(
|
||||
"Create a state of applicability first to link it to this control",
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<DialogContent padded className="space-y-4">
|
||||
{statesOfApplicability.length === 0
|
||||
? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="text-txt-secondary text-base mb-2">
|
||||
{__("No states of applicability available")}
|
||||
</div>
|
||||
<div className="text-txt-tertiary text-sm">
|
||||
{__(
|
||||
"Create a state of applicability first to link it to this control",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: !selectedSOA
|
||||
? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium mb-2">
|
||||
{__("Select a state of applicability:")}
|
||||
</div>
|
||||
) : !selectedSOA ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium mb-2">
|
||||
{__("Select a state of applicability:")}
|
||||
</div>
|
||||
{statesOfApplicability.map((soa) => {
|
||||
const isLinked = linkedSOAIds.has(soa.id);
|
||||
return (
|
||||
{statesOfApplicability.map((soa) => {
|
||||
const isLinked = linkedSOAIds.has(soa.id);
|
||||
return (
|
||||
<div
|
||||
key={soa.id}
|
||||
className={`border border-border-low rounded-lg p-3 flex items-center justify-between ${!isLinked ? "hover:bg-hover cursor-pointer" : ""}`}
|
||||
onClick={() =>
|
||||
!isLinked && handleSelectSOA(soa)}
|
||||
>
|
||||
<div className="font-medium">
|
||||
{soa.name}
|
||||
</div>
|
||||
{isLinked
|
||||
? (
|
||||
<div
|
||||
key={soa.id}
|
||||
className={`border border-border-low rounded-lg p-3 flex items-center justify-between ${!isLinked ? "hover:bg-hover cursor-pointer" : ""}`}
|
||||
onClick={() =>
|
||||
!isLinked && handleSelectSOA(soa)
|
||||
}
|
||||
className="flex items-center gap-2"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="font-medium">
|
||||
{soa.name}
|
||||
</div>
|
||||
{isLinked ? (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Badge variant="success">
|
||||
{__("Linked")}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() =>
|
||||
handleUnlink(soa.id)
|
||||
}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{__("Unlink")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<Badge variant="success">
|
||||
{__("Linked")}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() =>
|
||||
handleUnlink(soa.id)}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{__("Unlink")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-txt-secondary mb-1">
|
||||
{__("Selected:")}
|
||||
</div>
|
||||
<div className="text-lg font-medium">
|
||||
{selectedSOA.name}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => setSelectedSOA(null)}
|
||||
>
|
||||
{__("Change")}
|
||||
</Button>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-low pt-4 space-y-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={applicability}
|
||||
onChange={(checked) =>
|
||||
setApplicability(checked)
|
||||
}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
{__("Applicable")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">
|
||||
{__("Justification (optional)")}
|
||||
</label>
|
||||
<Textarea
|
||||
placeholder={__("Add a justification...")}
|
||||
value={justification}
|
||||
onChange={(e) =>
|
||||
setJustification(e.target.value)
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-txt-secondary mb-1">
|
||||
{__("Selected:")}
|
||||
</div>
|
||||
<div className="text-lg font-medium">
|
||||
{selectedSOA.name}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => setSelectedSOA(null)}
|
||||
>
|
||||
{__("Change")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-low pt-4 space-y-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={applicability}
|
||||
onChange={checked =>
|
||||
setApplicability(checked)}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
{__("Applicable")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">
|
||||
{__("Justification (optional)")}
|
||||
</label>
|
||||
<Textarea
|
||||
placeholder={__("Add a justification...")}
|
||||
value={justification}
|
||||
onChange={e =>
|
||||
setJustification(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogFooter exitLabel={__("Close")}>
|
||||
{selectedSOA ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setSelectedSOA(null)}
|
||||
>
|
||||
{__("Back")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleLink}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{__("Link")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
</DialogContent>
|
||||
<DialogFooter exitLabel={__("Close")}>
|
||||
{selectedSOA
|
||||
? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setSelectedSOA(null)}
|
||||
>
|
||||
{__("Back")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleLink}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{__("Link")}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<></>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export function EditableTable<
|
||||
))}
|
||||
<CellHead />
|
||||
</Row>
|
||||
{props.items.map((item) => (
|
||||
{props.items.map(item => (
|
||||
<EditableRow onUpdate={(k, v) => update(item.id, k, v)} key={item.id}>
|
||||
{props.row({
|
||||
item,
|
||||
@@ -91,18 +91,20 @@ export function EditableTable<
|
||||
<Cell>{props.action({ item })}</Cell>
|
||||
</EditableRow>
|
||||
))}
|
||||
{showAdd ? (
|
||||
<NewItemRow
|
||||
schema={props.schema}
|
||||
defaultValue={props.defaultValue}
|
||||
connectionId={props.connectionId}
|
||||
row={props.row}
|
||||
mutation={props.createMutation}
|
||||
onSuccess={toggleAdd}
|
||||
/>
|
||||
) : (
|
||||
<RowButton onClick={toggleAdd} type="button">{props.addLabel}</RowButton>
|
||||
)}
|
||||
{showAdd
|
||||
? (
|
||||
<NewItemRow
|
||||
schema={props.schema}
|
||||
defaultValue={props.defaultValue}
|
||||
connectionId={props.connectionId}
|
||||
row={props.row}
|
||||
mutation={props.createMutation}
|
||||
onSuccess={toggleAdd}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<RowButton onClick={toggleAdd} type="button">{props.addLabel}</RowButton>
|
||||
)}
|
||||
</SortableDataTable>
|
||||
);
|
||||
}
|
||||
@@ -144,7 +146,7 @@ function NewItemRow<T extends { id: string }, S extends z.ZodSchema>(props: {
|
||||
disabled={!isOk || isMutating}
|
||||
variant="tertiary"
|
||||
className={clsx(isOk ? "text-txt-success" : "text-txt-secondary")}
|
||||
onClick={onSubmit}
|
||||
onClick={() => void onSubmit()}
|
||||
>
|
||||
{isMutating ? <Spinner size={16} /> : <IconCheckmark1 size={16} />}
|
||||
</Button>
|
||||
|
||||
@@ -79,7 +79,8 @@ export function GraphQLCell<Q extends OperationType, T extends NonNullable<unkno
|
||||
value={value}
|
||||
itemRenderer={props.itemRenderer}
|
||||
/>
|
||||
</div>{" "}
|
||||
</div>
|
||||
{" "}
|
||||
{props.multiple && (
|
||||
<Command.Input
|
||||
className={classNames.input()}
|
||||
@@ -88,11 +89,11 @@ export function GraphQLCell<Q extends OperationType, T extends NonNullable<unkno
|
||||
)}
|
||||
<Command.List>
|
||||
<Suspense
|
||||
fallback={
|
||||
fallback={(
|
||||
<div className="py-2 px-3 flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
>
|
||||
<ItemList
|
||||
{...props}
|
||||
@@ -121,8 +122,8 @@ function ItemList<Q extends OperationType, T>(
|
||||
return (
|
||||
<>
|
||||
{items
|
||||
.filter((item) => !props.usedKeys.has(getKey(item) ?? ""))
|
||||
.map((item) => (
|
||||
.filter(item => !props.usedKeys.has(getKey(item) ?? ""))
|
||||
.map(item => (
|
||||
<Command.Item
|
||||
key={getKey(item)}
|
||||
className={props.className}
|
||||
|
||||
@@ -18,9 +18,8 @@ export function PeopleCell(props: Props) {
|
||||
organizationId: props.organizationId,
|
||||
filter: { excludeContractEnded: true },
|
||||
}}
|
||||
items={(data) =>
|
||||
data.organization?.peoples?.edges.map((edge) => edge.node) ?? []
|
||||
}
|
||||
items={data =>
|
||||
data.organization?.peoples?.edges.map(edge => edge.node) ?? []}
|
||||
itemRenderer={({ item }) => (
|
||||
<div className="flex gap-2 whitespace-nowrap items-center text-xs">
|
||||
<Avatar name={item.fullName} />
|
||||
|
||||
@@ -26,9 +26,8 @@ export function VendorsCell(props: Props) {
|
||||
variables={{
|
||||
organizationId: props.organizationId,
|
||||
}}
|
||||
items={(data) =>
|
||||
data.organization?.vendors?.edges?.map((edge) => edge.node) ?? []
|
||||
}
|
||||
items={data =>
|
||||
data.organization?.vendors?.edges?.map(edge => edge.node) ?? []}
|
||||
itemRenderer={({ item, onRemove }) => (
|
||||
<VendorBadge vendor={item} onRemove={onRemove} />
|
||||
)}
|
||||
|
||||
@@ -75,7 +75,7 @@ const createTaskSchema = z.object({
|
||||
timeEstimate: z.string().optional().nullable(),
|
||||
assignedToId: z.string().optional().nullable(),
|
||||
measureId: z.preprocess(
|
||||
(val) => (val === "" || val == null ? null : val),
|
||||
val => (val === "" || val == null ? null : val),
|
||||
z.string().nullable().optional(),
|
||||
),
|
||||
deadline: z.string().optional().nullable(),
|
||||
@@ -86,11 +86,11 @@ const updateTaskSchema = z.object({
|
||||
description: z.string().optional().nullable(),
|
||||
timeEstimate: z.string().optional().nullable(),
|
||||
assignedToId: z.preprocess(
|
||||
(val) => (val === "" || val == null ? null : val),
|
||||
val => (val === "" || val == null ? null : val),
|
||||
z.string().nullable().optional(),
|
||||
),
|
||||
measureId: z.preprocess(
|
||||
(val) => (val === "" || val == null ? null : val),
|
||||
val => (val === "" || val == null ? null : val),
|
||||
z.string().nullable().optional(),
|
||||
),
|
||||
deadline: z.string().optional().nullable(),
|
||||
@@ -105,11 +105,12 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function TaskFormDialog(props: Props) {
|
||||
const { children, connection, ref, task: taskKey, measureId } = props;
|
||||
const { __ } = useTranslate();
|
||||
const ref = useDialogRef();
|
||||
const dialogRef = props.ref ?? ref;
|
||||
const newRef = useDialogRef();
|
||||
const dialogRef = ref ?? newRef;
|
||||
const organizationId = useOrganizationId();
|
||||
const task = useFragment(taskFragment, props.task);
|
||||
const task = useFragment(taskFragment, taskKey);
|
||||
const relayEnv = useRelayEnvironment();
|
||||
const [mutate] = useMutationWithToasts(
|
||||
task ? taskUpdateMutation : taskCreateMutation,
|
||||
@@ -121,19 +122,19 @@ export default function TaskFormDialog(props: Props) {
|
||||
|
||||
const isUpdating = !!task;
|
||||
|
||||
const { control, handleSubmit, register, formState, reset } =
|
||||
useFormWithSchema(isUpdating ? updateTaskSchema : createTaskSchema, {
|
||||
const { control, handleSubmit, register, formState, reset }
|
||||
= useFormWithSchema(isUpdating ? updateTaskSchema : createTaskSchema, {
|
||||
defaultValues: {
|
||||
name: task?.name ?? "",
|
||||
description: task?.description ?? "",
|
||||
timeEstimate: task?.timeEstimate ?? "",
|
||||
assignedToId: task?.assignedTo?.id ?? "",
|
||||
measureId: task?.measure?.id ?? props.measureId ?? "",
|
||||
measureId: task?.measure?.id ?? measureId ?? "",
|
||||
deadline: task?.deadline?.split("T")[0] ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
const onSubmit = async (data: z.infer<typeof updateTaskSchema | typeof createTaskSchema>) => {
|
||||
if (task) {
|
||||
await mutate({
|
||||
variables: {
|
||||
@@ -160,7 +161,7 @@ export default function TaskFormDialog(props: Props) {
|
||||
assignedToId: data.assignedToId || null,
|
||||
measureId: data.measureId || null,
|
||||
},
|
||||
connections: [props.connection!],
|
||||
connections: [connection!],
|
||||
},
|
||||
onCompleted: (_response, errors) => {
|
||||
if (!errors && data.measureId) {
|
||||
@@ -171,20 +172,20 @@ export default function TaskFormDialog(props: Props) {
|
||||
reset();
|
||||
}
|
||||
dialogRef.current?.close();
|
||||
});
|
||||
const showMeasure = !props.measureId;
|
||||
};
|
||||
const showMeasure = !measureId;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
ref={dialogRef}
|
||||
trigger={props.children}
|
||||
title={
|
||||
trigger={children}
|
||||
title={(
|
||||
<Breadcrumb
|
||||
items={[__("Tasks"), isUpdating ? __("Edit Task") : __("New Task")]}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
>
|
||||
<form onSubmit={onSubmit}>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent className="grid grid-cols-[1fr_420px]">
|
||||
<div className="py-8 px-10 space-y-4">
|
||||
<Input
|
||||
@@ -240,7 +241,7 @@ export default function TaskFormDialog(props: Props) {
|
||||
<DurationPicker
|
||||
{...field}
|
||||
value={value ?? null}
|
||||
onValueChange={(value) => onChange(value)}
|
||||
onValueChange={value => onChange(value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -39,9 +39,9 @@ type Props = {
|
||||
tasks:
|
||||
| TasksPageFragment$data["tasks"]["edges"]
|
||||
| Extract<
|
||||
MeasureTasksTabQuery$data["node"],
|
||||
{ __typename: "Measure" }
|
||||
>["tasks"]["edges"];
|
||||
MeasureTasksTabQuery$data["node"],
|
||||
{ __typename: "Measure" }
|
||||
>["tasks"]["edges"];
|
||||
connectionId: string;
|
||||
};
|
||||
|
||||
@@ -67,52 +67,54 @@ export function TasksCard({ tasks, connectionId }: Props) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{tasks.length === 0 ? (
|
||||
<p className="text-center py-6 text-txt-secondary">{__("No tasks")}</p>
|
||||
) : (
|
||||
<Card>
|
||||
<Tabs className="px-6">
|
||||
{hashes.map((h) => (
|
||||
<TabItem asChild active={hash === h.hash} key={h.hash}>
|
||||
<Link to={`#${h.hash}`}>
|
||||
{h.label}
|
||||
<TabBadge>{tasksPerHash.get(h.hash)?.length}</TabBadge>
|
||||
</Link>
|
||||
</TabItem>
|
||||
))}
|
||||
</Tabs>
|
||||
<div className="divide-y divide-border-solid">
|
||||
{hash === "all"
|
||||
? // All tabs group the todo using the state
|
||||
hashes
|
||||
.slice(0, 2)
|
||||
.filter((h) => tasksPerHash.get(h.hash)?.length)
|
||||
.map((h) => (
|
||||
<Fragment key={h.label}>
|
||||
<h2 className="px-6 py-3 text-sm font-medium flex items-center gap-2 bg-subtle">
|
||||
<TaskStateIcon state={h.state!} />
|
||||
{h.label}
|
||||
</h2>
|
||||
{tasksPerHash.get(h.hash)?.map(({ node: task }) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
fKey={task}
|
||||
connectionId={connectionId}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))
|
||||
: // Todo and Done tab simply list todos
|
||||
filteredTasks?.map(({ node: task }) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
fKey={task}
|
||||
connectionId={connectionId}
|
||||
/>
|
||||
{tasks.length === 0
|
||||
? (
|
||||
<p className="text-center py-6 text-txt-secondary">{__("No tasks")}</p>
|
||||
)
|
||||
: (
|
||||
<Card>
|
||||
<Tabs className="px-6">
|
||||
{hashes.map(h => (
|
||||
<TabItem asChild active={hash === h.hash} key={h.hash}>
|
||||
<Link to={`#${h.hash}`}>
|
||||
{h.label}
|
||||
<TabBadge>{tasksPerHash.get(h.hash)?.length}</TabBadge>
|
||||
</Link>
|
||||
</TabItem>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</Tabs>
|
||||
<div className="divide-y divide-border-solid">
|
||||
{hash === "all"
|
||||
// All tabs group the todo using the state
|
||||
? hashes
|
||||
.slice(0, 2)
|
||||
.filter(h => tasksPerHash.get(h.hash)?.length)
|
||||
.map(h => (
|
||||
<Fragment key={h.label}>
|
||||
<h2 className="px-6 py-3 text-sm font-medium flex items-center gap-2 bg-subtle">
|
||||
<TaskStateIcon state={h.state!} />
|
||||
{h.label}
|
||||
</h2>
|
||||
{tasksPerHash.get(h.hash)?.map(({ node: task }) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
fKey={task}
|
||||
connectionId={connectionId}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))
|
||||
// Todo and Done tab simply list todos
|
||||
: filteredTasks?.map(({ node: task }) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
fKey={task}
|
||||
connectionId={connectionId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -163,15 +165,15 @@ function TaskRow(props: TaskRowProps) {
|
||||
const params = useParams<{ measureId?: string }>();
|
||||
|
||||
const relayEnv = useRelayEnvironment();
|
||||
const { canUpdate, canDelete, ...task } =
|
||||
useFragment<TasksCard_TaskRowFragment$key>(
|
||||
const { canUpdate, canDelete, ...task }
|
||||
= useFragment<TasksCard_TaskRowFragment$key>(
|
||||
fragment,
|
||||
props.fKey as TasksCard_TaskRowFragment$key,
|
||||
);
|
||||
const [updateTask, isUpdating] = useMutation(taskUpdateMutation);
|
||||
|
||||
const onToggle = () => {
|
||||
promisifyMutation(updateTask)({
|
||||
const onToggle = async () => {
|
||||
await promisifyMutation(updateTask)({
|
||||
variables: {
|
||||
input: {
|
||||
taskId: task.id,
|
||||
@@ -217,7 +219,7 @@ function TaskRow(props: TaskRowProps) {
|
||||
<div className="flex items-center gap-2 pt-[2px]">
|
||||
<PriorityLevel level={1} />
|
||||
<button
|
||||
onClick={onToggle}
|
||||
onClick={() => void onToggle()}
|
||||
className="cursor-pointer -m-1 p-1 disabled:opacity-60"
|
||||
disabled={isUpdating}
|
||||
>
|
||||
@@ -227,7 +229,7 @@ function TaskRow(props: TaskRowProps) {
|
||||
<div className="text-sm space-y-1 flex-1">
|
||||
<h2 className="font-medium">{task.name}</h2>
|
||||
{task.description && (
|
||||
<p className="text-txt-secondary whitespace-pre-wrap break-words">
|
||||
<p className="text-txt-secondary whitespace-pre-wrap wrap-break-word">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function DeleteTrustCenterReferenceDialog({
|
||||
<p className="text-txt-secondary">
|
||||
{sprintf(
|
||||
__("Are you sure you want to delete the reference \"%s\"?"),
|
||||
referenceName
|
||||
referenceName,
|
||||
)}
|
||||
</p>
|
||||
<p className="text-txt-secondary mt-2">
|
||||
@@ -71,7 +71,7 @@ export function DeleteTrustCenterReferenceDialog({
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDelete}
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={isDeleting}
|
||||
icon={isDeleting ? Spinner : IconTrashCan}
|
||||
>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@probo/ui";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { useFragment } from "react-relay";
|
||||
import { useMemo, useState, useCallback, useEffect } from "react";
|
||||
import { useMemo, useState, useCallback } from "react";
|
||||
import {
|
||||
sprintf,
|
||||
getAuditStateVariant,
|
||||
@@ -47,7 +47,7 @@ type Mutation<Params> = (p: {
|
||||
trustCenterVisibility: "NONE" | "PRIVATE" | "PUBLIC";
|
||||
} & Params;
|
||||
};
|
||||
}) => void;
|
||||
}) => Promise<void>;
|
||||
|
||||
type Props<Params> = {
|
||||
audits: TrustCenterAuditsCardFragment$key[];
|
||||
@@ -65,11 +65,11 @@ export function TrustCenterAuditsCard<Params>(props: Props<Params>) {
|
||||
}, [props.audits, limit]);
|
||||
const showMoreButton = limit !== null && props.audits.length > limit;
|
||||
|
||||
const onChangeVisibility = (
|
||||
const onChangeVisibility = async (
|
||||
auditId: string,
|
||||
trustCenterVisibility: "NONE" | "PRIVATE" | "PUBLIC",
|
||||
) => {
|
||||
props.onChangeVisibility({
|
||||
await props.onChangeVisibility({
|
||||
variables: {
|
||||
input: {
|
||||
id: auditId,
|
||||
@@ -130,7 +130,7 @@ function AuditRow(props: {
|
||||
onChangeVisibility: (
|
||||
auditId: string,
|
||||
trustCenterVisibility: "NONE" | "PRIVATE" | "PUBLIC",
|
||||
) => void;
|
||||
) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
canUpdate: boolean;
|
||||
}) {
|
||||
@@ -138,26 +138,16 @@ function AuditRow(props: {
|
||||
const audit = useFragment(trustCenterAuditFragment, auditFragmentRef);
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
const [optimisticValue, setOptimisticValue] = useState<string | null>(null);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: string) => {
|
||||
async (value: string) => {
|
||||
const stringValue = typeof value === "string" ? value : "";
|
||||
const typedValue = stringValue as "NONE" | "PRIVATE" | "PUBLIC";
|
||||
setOptimisticValue(typedValue);
|
||||
onChangeVisibility(audit.id, typedValue);
|
||||
await onChangeVisibility(audit.id, typedValue);
|
||||
},
|
||||
[audit.id, onChangeVisibility],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (optimisticValue && audit.trustCenterVisibility === optimisticValue) {
|
||||
setOptimisticValue(null);
|
||||
}
|
||||
}, [audit.trustCenterVisibility, optimisticValue]);
|
||||
|
||||
const currentValue = optimisticValue || audit.trustCenterVisibility;
|
||||
|
||||
const visibilityOptions = getTrustCenterVisibilityOptions(__);
|
||||
|
||||
const validUntilFormatted = audit.validUntil
|
||||
@@ -179,12 +169,12 @@ function AuditRow(props: {
|
||||
<Td noLink width={130} className="pr-0">
|
||||
<Field
|
||||
type="select"
|
||||
value={currentValue}
|
||||
onValueChange={handleValueChange}
|
||||
value={audit.trustCenterVisibility}
|
||||
onValueChange={value => void handleValueChange(value)}
|
||||
disabled={disabled || !canUpdate}
|
||||
className="w-[105px]"
|
||||
>
|
||||
{visibilityOptions.map((option) => (
|
||||
{visibilityOptions.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<Badge variant={option.variant}>{option.label}</Badge>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import type { TrustCenterDocumentsCardFragment$key } from "/__generated__/core/TrustCenterDocumentsCardFragment.graphql";
|
||||
import { useFragment } from "react-relay";
|
||||
import { useMemo, useState, useCallback, useEffect } from "react";
|
||||
import { useMemo, useState, useCallback } from "react";
|
||||
import { sprintf, getTrustCenterVisibilityOptions } from "@probo/helpers";
|
||||
import { useOrganizationId } from "/hooks/useOrganizationId";
|
||||
|
||||
@@ -46,7 +46,7 @@ type Mutation<Params> = (p: {
|
||||
trustCenterVisibility: "NONE" | "PRIVATE" | "PUBLIC";
|
||||
} & Params;
|
||||
};
|
||||
}) => void;
|
||||
}) => Promise<void>;
|
||||
|
||||
type Props<Params> = {
|
||||
documents: TrustCenterDocumentsCardFragment$key[];
|
||||
@@ -64,11 +64,11 @@ export function TrustCenterDocumentsCard<Params>(props: Props<Params>) {
|
||||
}, [props.documents, limit]);
|
||||
const showMoreButton = limit !== null && props.documents.length > limit;
|
||||
|
||||
const onChangeVisibility = (
|
||||
const onChangeVisibility = async (
|
||||
documentId: string,
|
||||
trustCenterVisibility: "NONE" | "PRIVATE" | "PUBLIC",
|
||||
) => {
|
||||
props.onChangeVisibility({
|
||||
await props.onChangeVisibility({
|
||||
variables: {
|
||||
input: {
|
||||
id: documentId,
|
||||
@@ -128,38 +128,28 @@ function DocumentRow(props: {
|
||||
onChangeVisibility: (
|
||||
documentId: string,
|
||||
trustCenterVisibility: "NONE" | "PRIVATE" | "PUBLIC",
|
||||
) => void;
|
||||
) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
canUpdate: boolean;
|
||||
}) {
|
||||
const { documentFragmentRef, onChangeVisibility, disabled, canUpdate } =
|
||||
props;
|
||||
const { documentFragmentRef, onChangeVisibility, disabled, canUpdate }
|
||||
= props;
|
||||
const document = useFragment(
|
||||
trustCenterDocumentFragment,
|
||||
documentFragmentRef,
|
||||
);
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
const [optimisticValue, setOptimisticValue] = useState<string | null>(null);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: string) => {
|
||||
async (value: string) => {
|
||||
const stringValue = typeof value === "string" ? value : "";
|
||||
const typedValue = stringValue as "NONE" | "PRIVATE" | "PUBLIC";
|
||||
setOptimisticValue(typedValue);
|
||||
onChangeVisibility(document.id, typedValue);
|
||||
await onChangeVisibility(document.id, typedValue);
|
||||
},
|
||||
[document.id, onChangeVisibility],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (optimisticValue && document.trustCenterVisibility === optimisticValue) {
|
||||
setOptimisticValue(null);
|
||||
}
|
||||
}, [document.trustCenterVisibility, optimisticValue]);
|
||||
|
||||
const currentValue = optimisticValue || document.trustCenterVisibility;
|
||||
|
||||
const visibilityOptions = getTrustCenterVisibilityOptions(__);
|
||||
|
||||
return (
|
||||
@@ -178,12 +168,12 @@ function DocumentRow(props: {
|
||||
<Td noLink width={130} className="pr-0">
|
||||
<Field
|
||||
type="select"
|
||||
value={currentValue}
|
||||
onValueChange={handleValueChange}
|
||||
value={document.trustCenterVisibility}
|
||||
onValueChange={value => void handleValueChange(value)}
|
||||
disabled={disabled || !canUpdate}
|
||||
className="w-[105px]"
|
||||
>
|
||||
{visibilityOptions.map((option) => (
|
||||
{visibilityOptions.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<Badge variant={option.variant}>{option.label}</Badge>
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
TrustCenterFilesCardFragment$data,
|
||||
} from "/__generated__/core/TrustCenterFilesCardFragment.graphql";
|
||||
import { useFragment } from "react-relay";
|
||||
import { useMemo, useState, useCallback, useEffect } from "react";
|
||||
import { useMemo, useState, useCallback } from "react";
|
||||
import { sprintf, getTrustCenterVisibilityOptions } from "@probo/helpers";
|
||||
import { formatDate } from "@probo/helpers";
|
||||
|
||||
@@ -46,7 +46,7 @@ type Mutation<Params> = (p: {
|
||||
trustCenterVisibility: "NONE" | "PRIVATE" | "PUBLIC";
|
||||
} & Params;
|
||||
};
|
||||
}) => void;
|
||||
}) => Promise<void>;
|
||||
|
||||
type Props<Params> = {
|
||||
files: TrustCenterFilesCardFragment$key[];
|
||||
@@ -66,11 +66,11 @@ export function TrustCenterFilesCard<Params>(props: Props<Params>) {
|
||||
}, [props.files, limit]);
|
||||
const showMoreButton = limit !== null && props.files.length > limit;
|
||||
|
||||
const onChangeVisibility = (
|
||||
const onChangeVisibility = async (
|
||||
fileId: string,
|
||||
trustCenterVisibility: "NONE" | "PRIVATE" | "PUBLIC",
|
||||
) => {
|
||||
props.onChangeVisibility({
|
||||
await props.onChangeVisibility({
|
||||
variables: {
|
||||
input: {
|
||||
id: fileId,
|
||||
@@ -133,7 +133,7 @@ function FileRowWrapper(props: {
|
||||
onChangeVisibility: (
|
||||
fileId: string,
|
||||
trustCenterVisibility: "NONE" | "PRIVATE" | "PUBLIC",
|
||||
) => void;
|
||||
) => Promise<void>;
|
||||
onEdit: (file: { id: string; name: string; category: string }) => void;
|
||||
onDelete: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
@@ -157,35 +157,25 @@ function FileRow(props: {
|
||||
onChangeVisibility: (
|
||||
fileId: string,
|
||||
trustCenterVisibility: "NONE" | "PRIVATE" | "PUBLIC",
|
||||
) => void;
|
||||
) => Promise<void>;
|
||||
onEdit: (file: { id: string; name: string; category: string }) => void;
|
||||
onDelete: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
canUpdate: boolean;
|
||||
}) {
|
||||
const { file, onChangeVisibility, onEdit, onDelete, disabled, canUpdate } =
|
||||
props;
|
||||
const { file, onChangeVisibility, onEdit, onDelete, disabled, canUpdate }
|
||||
= props;
|
||||
const { __ } = useTranslate();
|
||||
const [optimisticValue, setOptimisticValue] = useState<string | null>(null);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: string) => {
|
||||
async (value: string) => {
|
||||
const stringValue = typeof value === "string" ? value : "";
|
||||
const typedValue = stringValue as "NONE" | "PRIVATE" | "PUBLIC";
|
||||
setOptimisticValue(typedValue);
|
||||
onChangeVisibility(file.id, typedValue);
|
||||
await onChangeVisibility(file.id, typedValue);
|
||||
},
|
||||
[file.id, onChangeVisibility],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (optimisticValue && file.trustCenterVisibility === optimisticValue) {
|
||||
setOptimisticValue(null);
|
||||
}
|
||||
}, [file.trustCenterVisibility, optimisticValue]);
|
||||
|
||||
const currentValue = optimisticValue || file.trustCenterVisibility;
|
||||
|
||||
const visibilityOptions = getTrustCenterVisibilityOptions(__);
|
||||
|
||||
return (
|
||||
@@ -198,12 +188,12 @@ function FileRow(props: {
|
||||
<Td noLink width={130} className="pr-0">
|
||||
<Field
|
||||
type="select"
|
||||
value={currentValue}
|
||||
onValueChange={handleValueChange}
|
||||
value={file.trustCenterVisibility}
|
||||
onValueChange={value => void handleValueChange(value)}
|
||||
disabled={disabled || !canUpdate}
|
||||
className="w-[105px]"
|
||||
>
|
||||
{visibilityOptions.map((option) => (
|
||||
{visibilityOptions.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<Badge variant={option.variant}>{option.label}</Badge>
|
||||
@@ -218,8 +208,7 @@ function FileRow(props: {
|
||||
variant="secondary"
|
||||
icon={IconArrowLink}
|
||||
onClick={() =>
|
||||
window.open(file.fileUrl, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
window.open(file.fileUrl, "_blank", "noopener,noreferrer")}
|
||||
title={__("Download")}
|
||||
/>
|
||||
{file.canUpdate && (
|
||||
@@ -231,8 +220,7 @@ function FileRow(props: {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
category: file.category,
|
||||
})
|
||||
}
|
||||
})}
|
||||
disabled={disabled}
|
||||
title={__("Edit")}
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { z } from "zod";
|
||||
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||
import {
|
||||
useCreateTrustCenterReferenceMutation,
|
||||
useUpdateTrustCenterReferenceMutation
|
||||
useUpdateTrustCenterReferenceMutation,
|
||||
} from "/hooks/graph/TrustCenterReferenceGraph";
|
||||
|
||||
const referenceSchema = z.object({
|
||||
@@ -44,7 +44,7 @@ export const TrustCenterReferenceDialog = forwardRef<TrustCenterReferenceDialogR
|
||||
function TrustCenterReferenceDialog({ children }, ref) {
|
||||
const { __ } = useTranslate();
|
||||
const dialogRef = useDialogRef();
|
||||
const [mode, setMode] = useState<'create' | 'edit'>('create');
|
||||
const [mode, setMode] = useState<"create" | "edit">("create");
|
||||
const [trustCenterId, setTrustCenterId] = useState<string>("");
|
||||
const [connectionId, setConnectionId] = useState<string>("");
|
||||
const [editReference, setEditReference] = useState<Reference | null>(null);
|
||||
@@ -61,12 +61,12 @@ export const TrustCenterReferenceDialog = forwardRef<TrustCenterReferenceDialogR
|
||||
description: "",
|
||||
websiteUrl: "",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openCreate: (tId: string, cId: string) => {
|
||||
setMode('create');
|
||||
setMode("create");
|
||||
setTrustCenterId(tId);
|
||||
setConnectionId(cId);
|
||||
setEditReference(null);
|
||||
@@ -79,7 +79,7 @@ export const TrustCenterReferenceDialog = forwardRef<TrustCenterReferenceDialogR
|
||||
dialogRef.current?.open();
|
||||
},
|
||||
openEdit: (reference: Reference) => {
|
||||
setMode('edit');
|
||||
setMode("edit");
|
||||
setEditReference(reference);
|
||||
setUploadedFile(null);
|
||||
reset({
|
||||
@@ -99,8 +99,8 @@ export const TrustCenterReferenceDialog = forwardRef<TrustCenterReferenceDialogR
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = handleSubmit(async (data: ReferenceFormData) => {
|
||||
if (mode === 'create') {
|
||||
const onSubmit = async (data: ReferenceFormData) => {
|
||||
if (mode === "create") {
|
||||
if (!uploadedFile) {
|
||||
return;
|
||||
}
|
||||
@@ -161,7 +161,7 @@ export const TrustCenterReferenceDialog = forwardRef<TrustCenterReferenceDialogR
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
@@ -169,12 +169,12 @@ export const TrustCenterReferenceDialog = forwardRef<TrustCenterReferenceDialogR
|
||||
};
|
||||
|
||||
const isSubmitting = isCreating || isUpdating;
|
||||
const title = mode === 'create' ? __("Add Reference") : __("Edit Reference");
|
||||
const title = mode === "create" ? __("Add Reference") : __("Edit Reference");
|
||||
|
||||
return (
|
||||
<>
|
||||
{children && (
|
||||
<span onClick={() => mode === 'create' && dialogRef.current?.open()}>
|
||||
<span onClick={() => mode === "create" && dialogRef.current?.open()}>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
@@ -185,7 +185,7 @@ export const TrustCenterReferenceDialog = forwardRef<TrustCenterReferenceDialogR
|
||||
className="max-w-2xl"
|
||||
onClose={handleClose}
|
||||
>
|
||||
<form onSubmit={onSubmit}>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent padded className="space-y-6">
|
||||
<Field
|
||||
{...register("name")}
|
||||
@@ -213,7 +213,7 @@ export const TrustCenterReferenceDialog = forwardRef<TrustCenterReferenceDialogR
|
||||
placeholder={__("https://example.com")}
|
||||
/>
|
||||
|
||||
{mode === 'edit' && (
|
||||
{mode === "edit" && (
|
||||
<Field
|
||||
{...register("rank", { valueAsNumber: true })}
|
||||
label={__("Rank")}
|
||||
@@ -239,18 +239,21 @@ export const TrustCenterReferenceDialog = forwardRef<TrustCenterReferenceDialogR
|
||||
/>
|
||||
{uploadedFile && (
|
||||
<div className="mt-2 p-3 bg-tertiary-subtle rounded-lg">
|
||||
<p className="text-sm font-medium">{__("Selected file")}:</p>
|
||||
<p className="text-sm font-medium">
|
||||
{__("Selected file")}
|
||||
:
|
||||
</p>
|
||||
<p className="text-sm text-txt-secondary">{uploadedFile.name}</p>
|
||||
</div>
|
||||
)}
|
||||
{mode === 'edit' && !uploadedFile && (
|
||||
{mode === "edit" && !uploadedFile && (
|
||||
<div className="mt-2 p-3 bg-tertiary-subtle rounded-lg">
|
||||
<p className="text-sm text-txt-secondary">
|
||||
{__("Current logo will be kept if no new file is uploaded")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{mode === 'create' && !uploadedFile && (
|
||||
{mode === "create" && !uploadedFile && (
|
||||
<div className="mt-2 p-3 bg-warning-subtle rounded-lg">
|
||||
<p className="text-sm">
|
||||
{__("Logo is required for new references")}
|
||||
@@ -263,15 +266,15 @@ export const TrustCenterReferenceDialog = forwardRef<TrustCenterReferenceDialogR
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || (mode === 'create' && !uploadedFile)}
|
||||
disabled={isSubmitting || (mode === "create" && !uploadedFile)}
|
||||
icon={isSubmitting ? Spinner : undefined}
|
||||
>
|
||||
{mode === 'create' ? __("Add Reference") : __("Update Reference")}
|
||||
{mode === "create" ? __("Add Reference") : __("Update Reference")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -49,8 +49,8 @@ export function TrustCenterReferencesSection({
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
const [refetchKey, setRefetchKey] = useState(0);
|
||||
const { node: trustCenterNode } =
|
||||
useLazyLoadQuery<TrustCenterReferenceGraphQuery>(
|
||||
const { node: trustCenterNode }
|
||||
= useLazyLoadQuery<TrustCenterReferenceGraphQuery>(
|
||||
trustCenterReferencesQuery,
|
||||
{ trustCenterId: trustCenterId || "" },
|
||||
{ fetchPolicy: "network-only", fetchKey: refetchKey },
|
||||
@@ -60,8 +60,8 @@ export function TrustCenterReferencesSection({
|
||||
}
|
||||
const [updateRank] = useUpdateTrustCenterReferenceRankMutation();
|
||||
|
||||
const references =
|
||||
trustCenterNode?.references?.edges?.map((edge) => edge.node) || [];
|
||||
const references
|
||||
= trustCenterNode?.references?.edges?.map(edge => edge.node) || [];
|
||||
const referencesConnectionId = trustCenterNode?.references?.__id || "";
|
||||
|
||||
const handleCreate = () => {
|
||||
@@ -89,7 +89,7 @@ export function TrustCenterReferencesSection({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (targetIndex: number) => {
|
||||
const handleDrop = async (targetIndex: number) => {
|
||||
if (draggedIndex === null || draggedIndex === targetIndex) {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
@@ -99,7 +99,7 @@ export function TrustCenterReferencesSection({
|
||||
const draggedRef = references[draggedIndex];
|
||||
const targetRank = references[targetIndex].rank;
|
||||
|
||||
updateRank({
|
||||
await updateRank({
|
||||
variables: {
|
||||
input: {
|
||||
id: draggedRef.id,
|
||||
@@ -107,7 +107,7 @@ export function TrustCenterReferencesSection({
|
||||
},
|
||||
},
|
||||
onCompleted: () => {
|
||||
setRefetchKey((prev) => prev + 1);
|
||||
setRefetchKey(prev => prev + 1);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -158,8 +158,8 @@ export function TrustCenterReferencesSection({
|
||||
connectionId={referencesConnectionId}
|
||||
onVisitWebsite={() => handleVisitWebsite(reference.websiteUrl)}
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={() => handleDrop(index)}
|
||||
onDragOver={e => handleDragOver(e, index)}
|
||||
onDrop={() => void handleDrop(index)}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
|
||||
@@ -38,7 +38,7 @@ type Mutation<Params> = (p: {
|
||||
showOnTrustCenter: boolean;
|
||||
} & Params;
|
||||
};
|
||||
}) => void;
|
||||
}) => Promise<void>;
|
||||
|
||||
type Props<Params> = {
|
||||
vendors: TrustCenterVendorsCardFragment$key[];
|
||||
@@ -55,8 +55,8 @@ export function TrustCenterVendorsCard<Params>(props: Props<Params>) {
|
||||
}, [props.vendors, limit]);
|
||||
const showMoreButton = limit !== null && props.vendors.length > limit;
|
||||
|
||||
const onToggleVisibility = (vendorId: string, showOnTrustCenter: boolean) => {
|
||||
props.onToggleVisibility({
|
||||
const onToggleVisibility = async (vendorId: string, showOnTrustCenter: boolean) => {
|
||||
await props.onToggleVisibility({
|
||||
variables: {
|
||||
input: {
|
||||
id: vendorId,
|
||||
@@ -112,7 +112,7 @@ export function TrustCenterVendorsCard<Params>(props: Props<Params>) {
|
||||
|
||||
function VendorRow(props: {
|
||||
vendor: TrustCenterVendorsCardFragment$key;
|
||||
onToggleVisibility: (vendorId: string, showOnTrustCenter: boolean) => void;
|
||||
onToggleVisibility: (vendorId: string, showOnTrustCenter: boolean) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const vendor = useFragment(trustCenterVendorFragment, props.vendor);
|
||||
@@ -137,8 +137,7 @@ function VendorRow(props: {
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
props.onToggleVisibility(vendor.id, !vendor.showOnTrustCenter)
|
||||
}
|
||||
void props.onToggleVisibility(vendor.id, !vendor.showOnTrustCenter)}
|
||||
icon={vendor.showOnTrustCenter ? IconCrossLargeX : IconCheckmark1}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user