Source: react/components/layout/attachment/index.js

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;