import {forwardRef, useState, useMemo, useCallback, memo, useEffect, Fragment, useId} from "react";
import {distinctUntilChanged, tap} from "rxjs/operators";
import useStoreSelector from "@/shared/hook/useStoreSelector";
import getStore from "@/shared/lib/store";
import * as Sentry from "@sentry/nextjs";
import {of} from "rxjs";
import {getValueAtExactPath} from "@ogd-software/json-utils";
import {FileTrigger, Text, DropZone } from 'react-aria-components';
import {ArchiveBoxIcon as ArchiveBoxIconSolid, ArrowUturnLeftIcon, PaperClipIcon} from "@heroicons/react/20/solid";
import FileSyncHelper, {
useFileUploadProcess,
useFileUploadStatus
} from "@/shared/helper/sync/file";
import classNames from "classnames";
import WhitePrimarySmallButton from "@/shared/components/variant/button/whiteSmallNoBorder";
import WhiteSmallButton from "@/shared/components/variant/button/whiteSmall";
import { Menu, Transition } from "@headlessui/react";
import { EllipsisVerticalIcon, ArchiveBoxIcon } from "@heroicons/react/24/outline";
import {useStoreValues} from "@/shared/context/storeValues";
import HeaderWithDescription from "@/shared/components/variant/headerWithDescription";
const emptyFn = () => {};
function useAttachments({ storeType, storeId, groupName, storeRef, readOnly = false }) {
const [ownUploadingFileIds, setOwnUploadingFileIds] = useState({});
const store$ = useMemo(() => {
const store$ = getStore(storeType, storeId, { createInstance: false })?.getStore()?.storePipe$;
if (!store$) {
Sentry.withScope(function (scope) {
scope.setContext("Info", { storeId, storeType, id: storeRef });
Sentry.captureMessage("Attachments is called without valid store reference", "fatal");
});
return of(false);
}
return store$.pipe(
distinctUntilChanged((a, b) => JSON.stringify(getValueAtExactPath(storeRef, a, { defaultValueOnMiss: null })) === JSON.stringify(getValueAtExactPath(storeRef, b, { defaultValueOnMiss: null }))),
);
}, [storeId, storeType, storeRef]);
const addAttachments = useCallback(async (input) => {
const [uploadArray, attachToStorePromise] = await FileSyncHelper.addFiles(input, {
storeId,
storeType,
attachAddedFilesToStore: true,
storeRef,
groupName,
});
if (uploadArray.length === 0) {
return;
}
const [allProcessedItems] = [] = await attachToStorePromise;
const newItems = allProcessedItems.filter(([_, __, ___, uploadPromise, skipped]) => !!uploadPromise || skipped);
if (newItems.length > 0) {
setOwnUploadingFileIds((obj) => ({
...obj,
...Object.fromEntries(newItems
.map(([filePath, fileId, referenceId, promise]) =>
!!promise ? [filePath, { fileId, referenceId}] : undefined)
.filter((v) => !!v))
}));
}
}, [storeType, storeId, storeRef, groupName, setOwnUploadingFileIds]);
const selector = useCallback((selectorOptions, snapshot) => {
const data = getValueAtExactPath(`${selectorOptions.preSelector}${storeRef}`, snapshot, {
defaultValueOnMiss: selectorOptions.defaultValueOnMiss,
defaultValueCallback: selectorOptions.defaultValueCallback,
});
const sortByArchiveState = (obj) => {
const groups = Object.groupBy(obj, ({ archived }) => archived ? "archived" : "active");
const active = groups.active?.sort(({ epoch: a }, { epoch: b }) => a ?? 0 - b ?? 0) ?? [];
const archived = groups.archived?.sort(({ epoch: a }, { epoch: b }) => a ?? 0 - b ?? 0) ?? [];
return active.concat(archived);
}
if (!groupName || groupName === "ALL") {
return sortByArchiveState(data);
}
return sortByArchiveState(data.filter((obj) => obj.groupName === groupName));
}, [groupName, storeRef]);
const [value, onChange] = useStoreSelector({
storeType,
storeId,
store$,
selector,
searchInFormState: false,
managedByElementMachine: false,
defaultValueOnMiss: [],
selectorChangeValue: readOnly ? emptyFn : addAttachments,
});
return {
value,
addAttachments: onChange,
ownUploadingFileIds,
}
}
const AttachmentMenu = function ({ onChange, modifier = "default" }) {
return (
<Menu as="div" className={classNames("flex-none text-gray-400 relative h-full", modifier === "hidden" ? "invisible" : "")}>
<div className="items-center">
<Menu.Button className="flex items-center text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-gray-100">
<span className="sr-only">Open bijlage opties</span>
<EllipsisVerticalIcon className="h-6 w-6" aria-hidden="true" />
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className={classNames(
"absolute right-0 z-10 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
)}
>
<div className="py-1">
<Menu.Item>
{({ active, close }) => (
<>
{modifier === "default" && (
<span
onClick={() => {
close();
// You need about 50ms to close the menu before doing the update, as you otherwise see the 'new' menu appear
setTimeout(() => {
onChange("archive");
}, 50);
}}
className={classNames(active ? "bg-gray-100 text-gray-900" : "text-gray-700", "w-full inline-flex items-center px-4 py-1 text-sm/6 cursor-pointer hover:bg-gray-100")}
>
<ArchiveBoxIcon aria-hidden={true} className="w-7 h-7 shrink-0 pr-3 text-gray-400" />
Archiveer</span>
)}
</>
)}
</Menu.Item>
<Menu.Item>
{({ active, close }) => (
<>
{modifier === "archived" && (
<span
onClick={() => {
close();
// You need about 50ms to close the menu before doing the update, as you otherwise see the 'new' menu appear
setTimeout(() => {
onChange("restore");
}, 50);
}}
className={classNames(active ? "bg-gray-100 text-green-900" : "text-green-600", "w-full inline-flex items-center px-4 py-1 text-sm/6 cursor-pointer hover:bg-gray-100")}
>
<ArrowUturnLeftIcon aria-hidden={true} className="w-7 h-7 shrink-0 pr-3 text-green-600" />
Terughalen uit archief
</span>
)}
</>
)}
</Menu.Item>
<Menu.Item>
{({ active, close }) => (
<>
{modifier === "archived" && (
<span
onClick={() => {
close();
// You need about 50ms to close the menu before doing the update, as you otherwise see the 'new' menu appear
setTimeout(() => {
onChange("remove");
}, 50);
}}
className={classNames(active ? "bg-gray-100 text-red-900" : "text-red-700", "w-full inline-flex items-center px-4 py-1 text-sm/6 cursor-pointer hover:bg-gray-100")}
>
<ArchiveBoxIconSolid aria-hidden={true} className="w-7 h-7 shrink-0 pr-3 text-red-700" />
Definitief verwijderen
</span>
)}
</>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
);
};
const emptyDownloadInfo = { expiresAt: null, downloadUrl: null };
const statusDisplayText = {
EMPTY: "Hier komt een bestand te staan",
UPLOADING: "Uploaden van bestand: ",
OTHER_USER_UPLOADING: "Andere gebruiker upload bestand: ",
UPLOAD_COMPLETED: "Geupload: ",
ADDED: "Opzetten van upload: ",
};
const y11nStatusDisplayText = {
UPLOADING: "Bestand wordt als bijlage geupload",
OTHER_USER_UPLOADING: "Een andere gebruiker is een bestand als bijlage aan het toevoegen.",
UPLOAD_COMPLETED: "Bestand is succesvol als bijlage toegevoegd.",
IDLE: "Bestand: ",
ADDED: "",
};
/**
*
* @param {null|number} uploadProgress
* @param {string} uploadDate
* @param {function} requestDownload
* @param {string} value
* @param {string} status
* @param {"default"|"invisible"|"archived"} modifier
* @param {function} onChange
* @returns {JSX.Element}
* @constructor
*/
function FileRow({ uploadProgress, uploadDate, requestDownload, value, status, modifier = "default", onChange }) {
// Note: tailwind issue created to resolve before:content space issue: https://github.com/tailwindlabs/tailwindcss/issues/12476
return (
<li className={classNames("pl-3 pr-2 py-2", modifier === "invisible" ? "invisible" : "", "flex items-center w-full", "before:font-normal before:text-xs before:text-gray-400", modifier !== "archived" ? "[counter-increment:attachment] before:[content:counters(attachment,'')'.']" : "before-whitespace")}>
<div className="inline-flex items-center w-full px-2">
<span className="sr-only">{y11nStatusDisplayText[status]}</span>
<div className={classNames("ml-4 flex min-w-0 flex-1 gap-2 text-xs", modifier === "archived" ? "font-italic text-gray-400" : "text-gray-900")}>
<span className="truncate">{statusDisplayText[status]}{value}</span><span className={classNames(uploadDate ? "block" : "hidden", "flex-shrink-0 text-gray-400", modifier === "archived" ? "font-italic" : "")}>{uploadDate}</span>
</div>
<div aria-hidden="true" className={classNames(status === "UPLOADING" ? "display w-full max-w-[7rem] shrink pl-2 sm:max-w-xs" : "hidden")}>
<div className="overflow-hidden rounded-full bg-gray-200">
<div className="h-2 rounded-full bg-sky-600" style={{ width: `${uploadProgress}%` }} />
</div>
</div>
{
["UPLOAD_COMPLETED", "IDLE"].includes(status) && (
<div className="flex ml-4 items-center flex-shrink-0">
{
modifier !== "archived" ? (
<WhitePrimarySmallButton onClick={requestDownload}>Download</WhitePrimarySmallButton>
) : (
<span className="text-gray-400 text-xs/4 font-italic">Gearchiveerd</span>
)
}
<AttachmentMenu onChange={onChange} modifier={modifier}/>
</div>
)
}
</div>
</li>
);
}
function FileRowWithUploadStatus({ fileId = null, value, referenceId, storeRef, ...props }) {
const [downloadInfo, setDownloadInfo] = useState(emptyDownloadInfo);
const uploadProgress = useFileUploadProcess(fileId);
const status = useFileUploadStatus(fileId, value);
const { storeType, storeId } = useStoreValues();
useEffect(() => {
setDownloadInfo(emptyDownloadInfo);
}, [setDownloadInfo, fileId]);
const onChange = useCallback(async (action) => {
if (referenceId) {
const obj = { storeRef, itemReferenceId: referenceId, storeType, storeId };
switch (action) {
case "archive":
await FileSyncHelper.archiveFile(obj);
break;
case "remove":
await FileSyncHelper.removeFile(obj);
break;
case "restore":
await FileSyncHelper.restoreFile(obj);
break;
default: {
Sentry.captureMessage(`Got a case condition with invalid action (${action})`);
}
}
}
}, [referenceId, storeRef, storeType, storeId]);
const fileName = FileSyncHelper.retrieveFileName({ fileId, filePath: value});
const uploadDate = useMemo(() => FileSyncHelper.getUploadDate({ fileId, filePath: value}), [fileId, value])
const requestDownload = useCallback(async () => {
const now = Math.floor(new Date().getTime() / 1000);
let link;
if (downloadInfo.downloadUrl && downloadInfo.expiresAt && now < downloadInfo.expiresAt) {
link = downloadInfo.downloadUrl;
} else {
const {downloadUrl, expiresAt} = await FileSyncHelper.createDownloadUrl({fileId, filePath: value});
setDownloadInfo({
downloadUrl,
expiresAt,
});
link = downloadUrl;
}
if (link) {
// anchor link
const element = document.createElement("a");
element.href = link;
element.referrerPolicy = "no-referrer";
element.class = "hidden";
element.target = "_blank"; // Because our URL is not the same origin we must open in another tab :(
// element.type = ""; // Could be used later on when we have verified the mimetype
element.download = FileSyncHelper.retrieveFileName({ fileId, filePath: value});
// simulate link click
window?.document.body.appendChild(element); // Required for this to work in FireFox
element.click();
window?.document.body.removeChild(element);
}
}, [downloadInfo, fileId, value, setDownloadInfo]);
return <FileRow {...props} onChange={onChange} uploadProgress={uploadProgress} value={fileName} uploadDate={uploadDate} requestDownload={requestDownload} status={status} />
}
const Attachments = memo(forwardRef(function Attachments({ headerElement = "h4", Icon, id, description, formStateId = "attachments", groupName = "RESERVED_NONE", readOnly = false }, ref) {
const { storeType, storeId } = useStoreValues();
const postfix = useId();
const storeRef = formStateId ?? id; // Must be very simple one, no deep object stuff
const { value, addAttachments, ownUploadingFileIds } = useAttachments({ groupName, storeType, storeId, storeRef, readOnly });
const onChange = useCallback((e) => {
// Files are through dropzone
const files = e.items.filter((file) => file.kind === 'file');
if (files.length > 0) {
Promise.all(files.map(async f => f.getFile())).then(addAttachments);
}
}, [addAttachments]);
const messageStyling = classNames("h-full min-w-full", // Height and width
"border-2 border-dashed border-gray-300 rounded-md", // Border
"inline-flex items-center justify-center", // Centralize content
"text-gray-500 text-sm leading-6 front-normal", // Default text markup
"self-stretch flex-grow space-x-2", // Take full height and place space between inline content
);
const addAttachmentsButton = readOnly === false ? <FileTrigger
allowsMultiple
onSelect={addAttachments}
>
<WhiteSmallButton>
<PaperClipIcon aria-hidden={true} className="w-4 h-4 text-gray-500"/>
<span className="ml-1">Bijlage uploaden</span>
</WhiteSmallButton>
</FileTrigger> : null;
return (
<>
<HeaderWithDescription headerElement={headerElement} label="Bijlagen" description={description} Icon={Icon} id={`${storeRef}.${groupName}.${postfix}`} menu={addAttachmentsButton}/>
<DropZone
aria-label="Sleep uw bestanden naar het betreffende vlak of klik op 'Bijlagen' om ze te uploaden als bijlage"
onDrop={onChange}
className="min-w-full h-full flex flex-col relative min-h-[3.5rem]"
>
{({isDropTarget}) => (
<>
{isDropTarget && !readOnly &&
<Text className={classNames(messageStyling,
"bg-sky-600/50 border-sky-700/80 border-2 border-dotted",
'absolute' // Place above existing attachments with a minimal height higher than one element
)}>
<PaperClipIcon aria-hidden={true} className="w-5 h-5 flex-shrink-0 text-white"/>
<span className="text-white">Laat nu het bestand los, het uploaden start dan vanzelf.</span>
</Text>
}
{(value.length === 0 && !readOnly && !isDropTarget) && (
<Text className={classNames(messageStyling)}>
<PaperClipIcon aria-hidden={true} className="w-5 h-5 flex-shrink-0 text-gray-300"/>
<span>Sleep hier een bestand op.</span>
<span className="text-gray-400">Het uploaden begint dan vanzelf.</span>
</Text>
)}
{(value.length === 0 && readOnly) && (
<Text className={classNames(messageStyling)}>
<PaperClipIcon aria-hidden={true} className="w-5 h-5 flex-shrink-0 text-gray-300"/>
<span>Er zijn (nog) geen bijlagen.</span>
</Text>
)}
{
value.length > 0 && (
<ol className={classNames("[counter-reset:attachment] border border-gray-300 rounded-md divide-y divide-y-300", isDropTarget ? "invisible" : "")}>
{ value.map(({ filePath, referenceId, archived }, i) => {
let modifier = archived === true ? "archived" : "default";
if (isDropTarget) {
modifier = "invisible";
}
return (<FileRowWithUploadStatus
key={`${filePath}-${i}`}
modifier={modifier}
value={filePath}
referenceId={referenceId}
storeRef={storeRef}
fileId={ownUploadingFileIds[filePath]?.fileId ?? null}/>
)})}
</ol>
)
}
</>
)}
</DropZone>
</>
);
}));
Attachments.displayName = "Attachments";
export default Attachments;