From 10eb92012ae6b023537143c6327b66c97d579fee Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 2 Nov 2023 14:06:47 +0200 Subject: [PATCH 01/15] working client side protocol validation --- .../_components/ProtocolUploader.tsx | 376 ++++++++++-------- .../ProtocolsTable/ProtocolsTable.tsx | 8 - app/(dashboard)/dashboard/protocols/page.tsx | 2 + package.json | 2 +- pnpm-lock.yaml | 8 +- 5 files changed, 219 insertions(+), 177 deletions(-) diff --git a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx index 9c51d867..cdf06b1d 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx @@ -4,12 +4,8 @@ import { ChevronDown, ChevronUp } from 'lucide-react'; import type { FileWithPath } from 'react-dropzone'; import { generateReactHelpers } from '@uploadthing/react/hooks'; import { useState, useCallback } from 'react'; - import { importProtocol } from '../_actions/importProtocol'; import { Button } from '~/components/ui/Button'; - -const { useUploadThing } = generateReactHelpers(); - import { Dialog, DialogContent, @@ -20,7 +16,39 @@ import { import type { UploadFileResponse } from 'uploadthing/client'; import { Collapsible, CollapsibleContent } from '~/components/ui/collapsible'; import ActiveProtocolSwitch from '~/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch'; -import { api } from '~/trpc/client'; +import { getProtocolJson } from '~/utils/protocolImport'; +import type { Protocol } from '@codaco/shared-consts'; + +const { useUploadThing } = generateReactHelpers(); + +type ReadAs = 'arrayBuffer' | 'binaryString' | 'dataURL' | 'text'; + +function readFileHelper( + file: Blob | File, + readAs: ReadAs = 'arrayBuffer', +): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener('error', (err) => { + reader.abort(); + reject(err); + }); + + reader.addEventListener('load', () => { + resolve(reader.result); + }); + + if (readAs === 'arrayBuffer') { + reader.readAsArrayBuffer(file); + } else if (readAs === 'binaryString') { + reader.readAsBinaryString(file); + } else if (readAs === 'dataURL') { + reader.readAsDataURL(file); + } else if (readAs === 'text') { + reader.readAsText(file, 'utf-8'); + } + }); +} export default function ProtocolUploader({ onUploaded, @@ -28,15 +56,6 @@ export default function ProtocolUploader({ onUploaded?: () => void; }) { const [open, setOpen] = useState(false); - const [showErrorDetails, setShowErrorDetails] = useState(false); - const [dialogContent, setDialogContent] = useState({ - title: 'Protocol import', - description: 'dfdsfds', - progress: true, - error: 'dsfsdf', - }); - - const utils = api.useUtils(); const handleUploadComplete = async ( res: UploadFileResponse[] | undefined, @@ -47,180 +66,209 @@ export default function ProtocolUploader({ const firstFile = res[0]; if (!firstFile) return; - setDialogContent({ - title: 'Protocol import', - description: 'Importing protocol...', - progress: true, - error: '', - }); const { error, success } = await importProtocol(firstFile); if (error || !success) { - setDialogContent({ - title: 'Protocol import', - description: 'Error importing protocol', - progress: false, - error: error ?? 'Unkown error occured', - }); return; } - - await utils.protocol.get.lastUploaded.refetch(); - - setDialogContent({ - title: 'Protocol import', - description: 'Protocol successfully imported!', - progress: false, - error: '', - }); }; const { startUpload } = useUploadThing('protocolUploader', { onClientUploadComplete: (res) => void handleUploadComplete(res), onUploadError: (error) => { setOpen(true); - setDialogContent({ - title: 'Protocol import', - description: 'Error uploading protocol', - progress: false, - error: error.message, - }); }, onUploadBegin: () => { setOpen(true); - setDialogContent({ - title: 'Protocol import', - description: 'Uploading protocol...', - progress: true, - error: '', - }); }, }); - const onDrop = useCallback( - (files: FileWithPath[]) => { - if (files && files[0]) { - // This a temporary workaround for upload thing. Upload thing will not generate S3 signed urls for - //Unknown files so we append the .zip extension to .netcanvas files so they can be uploaded to S3 - const file = new File([files[0]], `${files[0].name}.zip`, { - type: 'application/zip', - }); - - startUpload([file]).catch((e: Error) => { - // eslint-disable-next-line no-console - console.log(e); - setOpen(true); - setDialogContent({ - title: 'Protocol import', - description: 'Error uploading protocol', - progress: false, - error: e.message, - }); - }); - } - }, - [startUpload], - ); + const [processing, setProcessing] = useState(false); + const [statusText, setStatusText] = useState(null); const { getRootProps, getInputProps } = useDropzone({ - onDrop, - accept: { 'application/octect-stream': ['.netcanvas'] }, - }); + multiple: false, + onDropAccepted: async (acceptedFiles) => { + try { + setProcessing(true); + setStatusText('Processing...'); + console.log({ acceptedFiles }); + + const acceptedFile = acceptedFiles[0] as File; + + setStatusText('Reading file...'); + const content = await readFileHelper(acceptedFile); + + if (!content) { + setStatusText('Error reading file'); + setProcessing(false); + return; + } + + console.log('content', content); + + const JSZip = (await import('jszip')).default; + + console.log(JSZip); + const zip = await JSZip.loadAsync(content); + + console.log({ zip }); + + const protocolJson = (await getProtocolJson(zip)) as Protocol; + + // Validating protocol... + + const { validateProtocol, ValidationError } = await import( + '@codaco/protocol-validation' + ); + + try { + await validateProtocol(protocolJson); + } catch (error) { + if (error instanceof ValidationError) { + return { + error: error.message, + errorDetails: [...error.logicErrors, ...error.schemaErrors], + success: false, + }; + } - function handleFinishImport() { - onUploaded?.(); - setOpen(false); - } - - const { data: lastUploadedProtocol } = api.protocol.get.lastUploaded.useQuery( - undefined, - { - onError(error) { - throw new Error(error.message); - }, + throw error; + } + + console.log('protocol is valid!'); + + setProcessing(false); + setStatusText(null); + } catch (e) { + console.log(e); + setProcessing(false); + setStatusText('Error with process'); + } + + // if (files && files[0]) { + // startUpload([file]).catch((e: Error) => { + // // eslint-disable-next-line no-console + // console.log(e); + // setOpen(true); + // setDialogContent({ + // title: 'Protocol import', + // description: 'Error uploading protocol', + // progress: false, + // error: e.message, + // }); + // }); + // } }, - ); + accept: { + 'application/octect-stream': ['.netcanvas'], + 'application/zip': ['.netcanvas'], + }, + }); return ( <> -
- -
Click to select .netcanvas file or drag and drop here
-
- - - - - {dialogContent.title} - {dialogContent.description} - - {dialogContent.progress && ( -
-
-
-
-
- )} - {dialogContent.error && ( - - - - - {dialogContent.error} - - - - )} - {!dialogContent.progress && - !dialogContent.error && - lastUploadedProtocol && ( -
-
-
-
-
- -

- Only one protocol may be active at a time. If you - already have an active protocol, activating this one - will make it inactive. -

-
-
- -
-
-
-
- -
- )} -
-
+ {processing ? ( + <> +

Processing...

+

{statusText}

+ + + ) : ( +
+ {statusText &&

{statusText}

} + +
Click to select .netcanvas file or drag and drop here
+
+ )} ); } + +const ProgressDialog = ({ content }: { content: string }) => { + return ( + + + {content} + + + ); +}; + +// const ProgressDialog = () => { +// return ( +// +// +// +// {dialogContent.title} +// {dialogContent.description} +// +// {dialogContent.progress && ( +//
+//
+//
+//
+//
+// )} +// {dialogContent.error && ( +// +// +// +// +// {dialogContent.error} +// +// +// +// )} +// {!dialogContent.progress && +// !dialogContent.error && +// lastUploadedProtocol && ( +//
+//
+//
+//
+//
+// +//

+// Only one protocol may be active at a time. If you +// already have an active protocol, activating this one +// will make it inactive. +//

+//
+//
+// +//
+//
+//
+//
+// +//
+// )} +//
+//
+// ); +// } diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx index 6c1e83b4..4919f093 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx @@ -7,7 +7,6 @@ import { api } from '~/trpc/client'; import { DeleteProtocolsDialog } from '~/app/(dashboard)/dashboard/protocols/_components/DeleteProtocolsDialog'; import { useState } from 'react'; import type { ProtocolWithInterviews } from '~/shared/types'; -import ImportProtocolModal from '~/app/(dashboard)/dashboard/protocols/_components/ImportProtocolModal'; export const ProtocolsTable = ({ initialData, @@ -29,21 +28,14 @@ export const ProtocolsTable = ({ const [protocolsToDelete, setProtocolsToDelete] = useState(); - const utils = api.useUtils(); - const handleDelete = (data: ProtocolWithInterviews[]) => { setProtocolsToDelete(data); setShowAlertDialog(true); }; - const handleUploaded = () => { - void utils.protocol.get.all.refetch(); - }; - return ( <> {isLoading &&
Loading...
} - { return (

Protocols management view

+
); diff --git a/package.json b/package.json index d503480c..a863c71b 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "seed": "tsx prisma/seed.ts" }, "dependencies": { - "@codaco/protocol-validation": "3.0.0-alpha.1", + "@codaco/protocol-validation": "3.0.0-alpha.2", "@codaco/shared-consts": "^0.0.2", "@headlessui/react": "^1.7.17", "@hookform/resolvers": "^3.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81ae97ba..ad120ef6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: dependencies: '@codaco/protocol-validation': - specifier: 3.0.0-alpha.1 - version: 3.0.0-alpha.1(@types/eslint@8.44.6)(eslint-config-prettier@9.0.0)(eslint@8.52.0) + specifier: 3.0.0-alpha.2 + version: 3.0.0-alpha.2(@types/eslint@8.44.6)(eslint-config-prettier@9.0.0)(eslint@8.52.0) '@codaco/shared-consts': specifier: ^0.0.2 version: 0.0.2 @@ -1677,8 +1677,8 @@ packages: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true - /@codaco/protocol-validation@3.0.0-alpha.1(@types/eslint@8.44.6)(eslint-config-prettier@9.0.0)(eslint@8.52.0): - resolution: {integrity: sha512-CucBlaX5/ojAShitLm5Ssdfu1LDYGf7OcwD4ZfNQfJjGamEZELcSMwXZ+XO3zTUBocnR1hG14Q/+djZ+uSXK5A==} + /@codaco/protocol-validation@3.0.0-alpha.2(@types/eslint@8.44.6)(eslint-config-prettier@9.0.0)(eslint@8.52.0): + resolution: {integrity: sha512-8eOC5sUiTl8sJhOZ2PsQ5exiogcx5oYKRb6epI28pA9dr2uXpYCiZ/P5CnZJJysDc4hzOoJddbR4L/3jFQNnCQ==} dependencies: '@codaco/shared-consts': 0.0.2 '@types/lodash-es': 4.17.10 From 6660e2428c449a1252d1a00d5397954d26bf98f1 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 7 Nov 2023 21:08:53 +0200 Subject: [PATCH 02/15] working client upload of assets. WIP refactor of asset schema --- .../dashboard/_actions/importProtocol.ts | 179 ---------- .../_components/ActiveProtocolSwitch.tsx | 12 +- .../_components/ProtocolUploader.tsx | 338 +++++++----------- app/api/uploadthing/core.ts | 24 +- components/ErrorAlert.tsx | 34 ++ components/ui/ErrorDialog.tsx | 52 +++ lib/uploadthing-helpers.ts | 5 + prisma/schema.prisma | 6 +- server/routers/protocol.ts | 240 +++++++++---- utils/EventEmitter.ts | 27 ++ utils/protocolImport.tsx | 88 +++++ utils/uploadthing.ts | 6 - utils/uploadthing/useEvent.ts | 53 --- utils/uploadthing/useFetch.ts | 90 ----- 14 files changed, 528 insertions(+), 626 deletions(-) delete mode 100644 app/(dashboard)/dashboard/_actions/importProtocol.ts create mode 100644 components/ErrorAlert.tsx create mode 100644 components/ui/ErrorDialog.tsx create mode 100644 lib/uploadthing-helpers.ts create mode 100644 utils/EventEmitter.ts create mode 100644 utils/protocolImport.tsx delete mode 100644 utils/uploadthing.ts delete mode 100644 utils/uploadthing/useEvent.ts delete mode 100644 utils/uploadthing/useFetch.ts diff --git a/app/(dashboard)/dashboard/_actions/importProtocol.ts b/app/(dashboard)/dashboard/_actions/importProtocol.ts deleted file mode 100644 index 53b27912..00000000 --- a/app/(dashboard)/dashboard/_actions/importProtocol.ts +++ /dev/null @@ -1,179 +0,0 @@ -'use server'; -import type { Asset } from '@prisma/client'; -import { hash } from 'bcrypt'; -import type Zip from 'jszip'; -import JSZip from 'jszip'; -import type { UploadFileResponse } from 'uploadthing/client'; -import type { FileEsque } from 'uploadthing/dist/sdk/utils'; -import { utapi } from 'uploadthing/server'; -import type { - AssetManifest, - Protocol as NCProtocol, -} from '@codaco/shared-consts'; -import { prisma } from '~/utils/db'; -import { ValidationError, validateProtocol } from '@codaco/protocol-validation'; - -// Move to utils -export const fetchFileAsBuffer = async (url: string) => { - const res = await fetch(url); - const buffer = await res.arrayBuffer(); - - return buffer; -}; - -export const getProtocolJson = async (zip: Zip) => { - const protocolString = await zip.file('protocol.json')?.async('string'); - - if (!protocolString) { - throw new Error('protocol.json not found in zip'); - } - - const protocol = await JSON.parse(protocolString); - - return protocol; -}; - -export const uploadProtocolAssets = async (protocol: NCProtocol, zip: Zip) => { - const assetManifest = protocol.assetManifest as AssetManifest; - - if (!assetManifest) { - return; - } - - const data = new FormData(); - - await Promise.all( - Object.keys(assetManifest).map(async (key) => { - const asset = assetManifest[key] as Asset; - - const fileParts = asset.source.split('.'); - const fileExtension = fileParts[fileParts.length - 1]; - - const blob = await zip.file(`assets/${asset.source}`)?.async('blob'); - - if (!blob) { - throw new Error('Asset not found in asset folder!'); - } - - const file = new Blob([blob], { - type: `application/${fileExtension}`, - }) as File; - - data.append('files', file, `${asset.id}.${fileExtension}`); - }), - ); - - const files = data.getAll('files') as FileEsque[]; - - const response = await utapi.uploadFiles(files); - - const assets = response.map((uploadedFile) => { - const assetKey = uploadedFile?.data?.name.split('.')[0]; - - if ( - uploadedFile.error || - !uploadedFile.data.name || - !assetKey || - !assetManifest[assetKey] - ) { - throw new Error('incomplete file uploads: name mismatch'); - } - - const asset = assetManifest[assetKey]; - - if (!asset?.name) { - throw new Error('incomplete file uploads: name mismatch'); - } - - const { id, ...otherAssetAttributes } = asset; - - return { - assetId: id as string, - ...otherAssetAttributes, - ...uploadedFile.data, - alias: asset.name, - }; - }); - - return assets; -}; - -export const insertProtocol = async ( - protocolName: string, - protocol: NCProtocol, - assets: Asset[] | undefined, -) => { - try { - const protocolHash = await hash(JSON.stringify(protocol), 8); - - // eslint-disable-next-line local-rules/require-data-mapper - await prisma.protocol.create({ - data: { - hash: protocolHash, - lastModified: protocol.lastModified, - name: protocolName, - schemaVersion: protocol.schemaVersion, - stages: JSON.stringify(protocol.stages), - codebook: JSON.stringify(protocol.codebook), - description: protocol.description, - assets: { - create: assets, - }, - }, - }); - } catch (e) { - throw new Error('Error adding to database'); - } -}; - -export const removeProtocolFromCloudStorage = async (fileKey: string) => { - const response = await utapi.deleteFiles(fileKey); - return response; -}; - -export const importProtocol = async (file: UploadFileResponse) => { - try { - const protocolName = file.name.split('.')[0]!; - - // Preparing protocol... - const buffer = await fetchFileAsBuffer(file.url); - - // Unzipping... - const zip = await JSZip.loadAsync(buffer); - - const protocolJson = (await getProtocolJson(zip)) as NCProtocol; - - // Validating protocol... - try { - await validateProtocol(protocolJson); - } catch (error) { - if (error instanceof ValidationError) { - return { - error: error.message, - errorDetails: [...error.logicErrors, ...error.schemaErrors], - success: false, - }; - } - - throw error; - } - - // Uploading assets... - const assets = (await uploadProtocolAssets(protocolJson, zip)) as Asset[]; - - // Inserting protocol... - await insertProtocol(protocolName, protocolJson, assets); - - // Removing protocol file...'); - await removeProtocolFromCloudStorage(file.key); - - // Done! - return { error: null, success: true }; - } catch (error) { - if (error instanceof Error) { - return { error: error.message, success: false }; - } - - return { error: 'Unknown error', success: false }; - } -}; diff --git a/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch.tsx b/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch.tsx index c8c71c9d..03d05889 100644 --- a/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch.tsx +++ b/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch.tsx @@ -14,28 +14,28 @@ const ActiveProtocolSwitch = ({ const utils = api.useUtils(); const router = useRouter(); - const { data: isActive } = api.protocol.getActive.useQuery(hash, { + const { data: isActive } = api.protocol.active.is.useQuery(hash, { initialData, onError: (err) => { throw new Error(err.message); }, }); - const { mutateAsync: setActive } = api.protocol.setActive.useMutation({ + const { mutateAsync: setActive } = api.protocol.active.set.useMutation({ async onMutate(variables) { const { input: newState, hash } = variables; - await utils.protocol.getActive.cancel(); + await utils.protocol.active.get.cancel(); - const previousState = utils.protocol.getActive.getData(); + const previousState = utils.protocol.active.get.getData(); if (hash) { - utils.protocol.getActive.setData(hash, newState); + utils.protocol.active.get.setData(hash, newState); } return previousState; }, onError: (err, _newState, previousState) => { - utils.protocol.getActive.setData(hash, previousState); + utils.protocol.active.get.setData(hash, previousState); throw new Error(err.message); }, onSuccess: () => { diff --git a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx index cdf06b1d..20fa45ae 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx @@ -1,122 +1,74 @@ 'use client'; +import { useState } from 'react'; import { useDropzone } from 'react-dropzone'; -import { ChevronDown, ChevronUp } from 'lucide-react'; -import type { FileWithPath } from 'react-dropzone'; -import { generateReactHelpers } from '@uploadthing/react/hooks'; -import { useState, useCallback } from 'react'; -import { importProtocol } from '../_actions/importProtocol'; import { Button } from '~/components/ui/Button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '~/components/ui/dialog'; -import type { UploadFileResponse } from 'uploadthing/client'; import { Collapsible, CollapsibleContent } from '~/components/ui/collapsible'; import ActiveProtocolSwitch from '~/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch'; -import { getProtocolJson } from '~/utils/protocolImport'; -import type { Protocol } from '@codaco/shared-consts'; - -const { useUploadThing } = generateReactHelpers(); - -type ReadAs = 'arrayBuffer' | 'binaryString' | 'dataURL' | 'text'; - -function readFileHelper( - file: Blob | File, - readAs: ReadAs = 'arrayBuffer', -): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.addEventListener('error', (err) => { - reader.abort(); - reject(err); - }); - - reader.addEventListener('load', () => { - resolve(reader.result); - }); - - if (readAs === 'arrayBuffer') { - reader.readAsArrayBuffer(file); - } else if (readAs === 'binaryString') { - reader.readAsBinaryString(file); - } else if (readAs === 'dataURL') { - reader.readAsDataURL(file); - } else if (readAs === 'text') { - reader.readAsText(file, 'utf-8'); - } - }); -} - -export default function ProtocolUploader({ - onUploaded, -}: { - onUploaded?: () => void; -}) { - const [open, setOpen] = useState(false); - - const handleUploadComplete = async ( - res: UploadFileResponse[] | undefined, - ) => { - if (!res) return; - - setOpen(true); - const firstFile = res[0]; - if (!firstFile) return; - - const { error, success } = await importProtocol(firstFile); - - if (error || !success) { - return; - } - }; +import { + getAssets, + getProtocolJson, + readFileHelper, +} from '~/utils/protocolImport'; +import ErrorDialog from '~/components/ui/ErrorDialog'; +import { useToast } from '~/components/ui/use-toast'; +import { Progress } from '~/components/ui/progress'; +import { api } from '~/trpc/client'; +import { uploadFiles } from '~/lib/uploadthing-helpers'; +import { clientRevalidateTag } from '~/utils/clientRevalidate'; + +type ErrorState = { + title: string; + description: React.ReactNode; + additionalContent?: React.ReactNode; +}; - const { startUpload } = useUploadThing('protocolUploader', { - onClientUploadComplete: (res) => void handleUploadComplete(res), - onUploadError: (error) => { - setOpen(true); - }, - onUploadBegin: () => { - setOpen(true); - }, - }); +type ProgressState = { + percent: number; + status: string; +}; - const [processing, setProcessing] = useState(false); - const [statusText, setStatusText] = useState(null); +export default function ProtocolUploader() { + const [error, setError] = useState(null); + const [progress, setProgress] = useState(null); + const { toast } = useToast(); + const { mutateAsync: insertProtocol } = api.protocol.insert.useMutation(); const { getRootProps, getInputProps } = useDropzone({ multiple: false, onDropAccepted: async (acceptedFiles) => { try { - setProcessing(true); - setStatusText('Processing...'); - console.log({ acceptedFiles }); + setProgress({ + percent: 0, + status: 'Processing...', + }); const acceptedFile = acceptedFiles[0] as File; + const fileName = acceptedFile.name; - setStatusText('Reading file...'); + setProgress({ + percent: 0, + status: 'Reading file...', + }); const content = await readFileHelper(acceptedFile); if (!content) { - setStatusText('Error reading file'); - setProcessing(false); + setError({ + title: 'Error reading file', + description: 'The file could not be read', + }); + setProgress(null); return; } - console.log('content', content); - const JSZip = (await import('jszip')).default; - - console.log(JSZip); const zip = await JSZip.loadAsync(content); - - console.log({ zip }); - - const protocolJson = (await getProtocolJson(zip)) as Protocol; + const protocolJson = await getProtocolJson(zip); // Validating protocol... + setProgress({ + percent: 0, + status: 'Validating protocol...', + }); const { validateProtocol, ValidationError } = await import( '@codaco/protocol-validation' @@ -126,39 +78,90 @@ export default function ProtocolUploader({ await validateProtocol(protocolJson); } catch (error) { if (error instanceof ValidationError) { - return { - error: error.message, - errorDetails: [...error.logicErrors, ...error.schemaErrors], - success: false, - }; + setError({ + title: 'Protocol was invalid!', + description: 'The protocol you uploaded was invalid.', + additionalContent: ( + + +
+

Errors:

+
    + {error.logicErrors.map((e, i) => ( +
  • {e}
  • + ))} + {error.schemaErrors.map((e, i) => ( +
  • {e}
  • + ))} +
+
+
+
+ ), + }); + setProgress(null); + return; } throw error; } - console.log('protocol is valid!'); - - setProcessing(false); - setStatusText(null); + // Protocol is valid, continue with import + const assets = await getAssets(protocolJson, zip); + + setProgress({ + percent: 0, + status: 'Uploading assets...', + }); + + // Calculate overall asset upload progress by summing the progress + // of each asset, then dividing by the total number of assets * 100. + const completeCount = assets.length * 100; + let currentProgress = 0; + + const response = await uploadFiles({ + files: assets, + endpoint: 'assetRouter', + onUploadProgress({ progress }) { + currentProgress += progress; + setProgress({ + percent: Math.round((currentProgress / completeCount) * 100), + status: 'Uploading assets...', + }); + }, + }); + + console.log('asset upload response', response); + + await insertProtocol({ + protocol: protocolJson, + protocolName: fileName, + assets: response.map((fileResponse) => ({ + assetId: fileResponse.key, + key: fileResponse.key, + source: fileResponse.key, + url: fileResponse.url, + name: fileResponse.name, + size: fileResponse.size, + })), + }); + + toast({ + title: 'Protocol imported!', + description: 'Your protocol has been successfully imported.', + variant: 'success', + }); + + setProgress(null); + await clientRevalidateTag('protocol.get.all'); } catch (e) { console.log(e); - setProcessing(false); - setStatusText('Error with process'); + setError({ + title: 'Error importing protocol', + description: e.message, + }); + setProgress(null); } - - // if (files && files[0]) { - // startUpload([file]).catch((e: Error) => { - // // eslint-disable-next-line no-console - // console.log(e); - // setOpen(true); - // setDialogContent({ - // title: 'Protocol import', - // description: 'Error uploading protocol', - // progress: false, - // error: e.message, - // }); - // }); - // } }, accept: { 'application/octect-stream': ['.netcanvas'], @@ -168,10 +171,18 @@ export default function ProtocolUploader({ return ( <> - {processing ? ( + setError(null)} + title={error?.title} + description={error?.description} + additionalContent={error?.additionalContent} + /> + {progress ? ( <> -

Processing...

-

{statusText}

+

{progress.status}

+ +
) : ( @@ -179,7 +190,6 @@ export default function ProtocolUploader({ {...getRootProps()} className="mt-2 rounded-xl border-2 border-dashed border-gray-500 bg-gray-200 p-12 text-center" > - {statusText &&

{statusText}

} -// -// -// {dialogContent.error} -// -// -// -// )} -// {!dialogContent.progress && -// !dialogContent.error && -// lastUploadedProtocol && ( -//
-//
-//
-//
-//
-// -//

-// Only one protocol may be active at a time. If you -// already have an active protocol, activating this one -// will make it inactive. -//

-//
-//
-// -//
-//
-//
-//
-// -//
-// )} -// -// -// ); -// } diff --git a/app/api/uploadthing/core.ts b/app/api/uploadthing/core.ts index 2a675bfd..01bded80 100644 --- a/app/api/uploadthing/core.ts +++ b/app/api/uploadthing/core.ts @@ -1,23 +1,29 @@ -import { createUploadthing, type FileRouter } from 'uploadthing/next'; +import { createUploadthing } from 'uploadthing/next'; +import { UTApi } from 'uploadthing/server'; import { getServerSession } from '~/utils/auth'; const f = createUploadthing(); // FileRouter for your app, can contain multiple FileRoutes export const ourFileRouter = { - // Define as many FileRoutes as you like, each with a unique routeSlug - protocolUploader: f({ - 'application/zip': { maxFileSize: '256MB', maxFileCount: 5 }, + assetRouter: f({ + blob: { maxFileSize: '256MB', maxFileCount: 10 }, }) - // Set permissions and file types for this FileRoute .middleware(async () => { const session = await getServerSession(); - if (!session?.user) { - throw new Error('Unauthorized'); + if (!session) { + throw new Error('You must be logged in to upload assets.'); } return {}; }) - .onUploadComplete(async () => {}), -} satisfies FileRouter; + .onUploadError((error) => { + console.log('assetRouter onUploadError', error); + }) + .onUploadComplete((file) => { + console.log('assetRouter onUploadComplete', file); + }), +}; + +export const utapi = new UTApi(); export type OurFileRouter = typeof ourFileRouter; diff --git a/components/ErrorAlert.tsx b/components/ErrorAlert.tsx new file mode 100644 index 00000000..a2b41921 --- /dev/null +++ b/components/ErrorAlert.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useEffect } from 'react'; +import { Button } from '~/components/ui/Button'; +import { AlertTriangle } from 'lucide-react'; + +export default function ErrorAlert({ + error, + reset, + heading, +}: { + error: Error; + reset: () => void; + heading?: string; +}) { + useEffect(() => { + // Log the error to an error reporting service + // eslint-disable-next-line no-console + console.error(error); + }, [error]); + + return ( +
+ +

+ {heading || 'Something went wrong'} +

+

{error.message}

+
+ +
+
+ ); +} diff --git a/components/ui/ErrorDialog.tsx b/components/ui/ErrorDialog.tsx new file mode 100644 index 00000000..a1da9fe8 --- /dev/null +++ b/components/ui/ErrorDialog.tsx @@ -0,0 +1,52 @@ +'use client'; + +import type { AlertDialogProps } from '@radix-ui/react-alert-dialog'; +import React from 'react'; +import { + AlertDialogHeader, + AlertDialogFooter, + AlertDialog, + AlertDialogContent, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, +} from '~/components/ui/AlertDialog'; + +type ErrorDialogProps = AlertDialogProps & { + title?: string; + description?: React.ReactNode; + confirmLabel?: string; + additionalContent?: React.ReactNode; + onConfirm?: () => void; +}; + +const ErrorDialog = ({ + open, + onOpenChange, + onConfirm = () => {}, + title = 'Error', + description, + confirmLabel = 'OK', + additionalContent, +}: ErrorDialogProps) => { + return ( + + + + {title} + {description && ( + {description} + )} + {additionalContent} + + + + {confirmLabel} + + + + + ); +}; + +export default ErrorDialog; diff --git a/lib/uploadthing-helpers.ts b/lib/uploadthing-helpers.ts new file mode 100644 index 00000000..e2ea41a2 --- /dev/null +++ b/lib/uploadthing-helpers.ts @@ -0,0 +1,5 @@ +import { generateReactHelpers } from '@uploadthing/react/hooks'; +import type { OurFileRouter } from '~/app/api/uploadthing/core'; + +export const { uploadFiles, useUploadThing } = + generateReactHelpers(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5ab52397..c399b75f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,13 +29,11 @@ model Protocol { model Asset { id String @id @default(cuid()) - assetId String // from manifest - type String // from manifest + name String // from upload thing source String // from manifest - alias String // name as from manifest + type String // from manifest key String // from upload thing url String // from upload thing - name String // from upload thing size Int // from upload thing protocol Protocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) protocolId String // from db diff --git a/server/routers/protocol.ts b/server/routers/protocol.ts index 9e9469cd..98b04311 100644 --- a/server/routers/protocol.ts +++ b/server/routers/protocol.ts @@ -2,12 +2,66 @@ import { prisma } from '~/utils/db'; import { protectedProcedure, router } from '~/server/trpc'; import { z } from 'zod'; +import { hash } from 'bcrypt'; +import { Prisma } from '@prisma/client'; +import { utapi } from '~/app/api/uploadthing/core'; const updateActiveProtocolSchema = z.object({ input: z.boolean(), hash: z.string(), }); +// When deleting protocols we must first delete the assets associated with them +// from the cloud storage. +export const deleteProtocols = async (hashes: string[]) => { + // We put asset deletion in a separate try/catch because if it fails, we still + // want to delete the protocol. + try { + // eslint-disable-next-line no-console + console.log('deleting protocol assets...'); + const protocolIds = await prisma.protocol.findMany({ + where: { hash: { in: hashes } }, + select: { id: true }, + }); + + const assets = await prisma.asset.findMany({ + where: { protocolId: { in: protocolIds.map((p) => p.id) } }, + select: { key: true }, + }); + + await deleteFilesFromUploadThing(assets.map((a) => a.key)); + } catch (error) { + // eslint-disable-next-line no-console + console.log('Error deleting protocol assets!', error); + } + + try { + const deletedProtocols = await prisma.protocol.deleteMany({ + where: { hash: { in: hashes } }, + }); + return { error: null, deletedProtocols: deletedProtocols }; + } catch (error) { + // eslint-disable-next-line no-console + console.log('delete protocols error: ', error); + return { + error: 'Failed to delete protocols', + deletedProtocols: null, + }; + } +}; + +export const deleteFilesFromUploadThing = async ( + fileKey: string | string[], +) => { + const response = await utapi.deleteFiles(fileKey); + + if (!response.success) { + throw new Error('Failed to delete files from uploadthing'); + } + + return response; +}; + export const protocolRouter = router({ get: router({ all: protectedProcedure.query(async () => { @@ -16,7 +70,6 @@ export const protocolRouter = router({ }); return protocols; }), - byHash: protectedProcedure .input(z.string()) .query(async ({ input: hash }) => { @@ -27,7 +80,6 @@ export const protocolRouter = router({ }); return protocol; }), - lastUploaded: protectedProcedure.query(async () => { const protocol = await prisma.protocol.findFirst({ orderBy: { @@ -37,9 +89,16 @@ export const protocolRouter = router({ return protocol; }), }), - getActive: protectedProcedure - .input(z.string()) - .query(async ({ input: hash }) => { + active: router({ + get: protectedProcedure.query(async () => { + const protocol = await prisma.protocol.findFirst({ + where: { + active: true, + }, + }); + return protocol; + }), + is: protectedProcedure.input(z.string()).query(async ({ input: hash }) => { const protocol = await prisma.protocol.findFirst({ where: { hash, @@ -50,93 +109,126 @@ export const protocolRouter = router({ }); return protocol?.active || false; }), - - getCurrentlyActive: protectedProcedure.query(async () => { - const protocol = await prisma.protocol.findFirst({ - where: { - active: true, - }, - }); - return protocol; - }), - - setActive: protectedProcedure - .input(updateActiveProtocolSchema) - .mutation(async ({ input: { input, hash } }) => { - try { - const currentActive = await prisma.protocol.findFirst({ - where: { - active: true, - }, - }); - - // If input is false, deactivate the active protocol - if (!input) { - await prisma.protocol.update({ + set: protectedProcedure + .input(updateActiveProtocolSchema) + .mutation(async ({ input: { input, hash } }) => { + try { + const currentActive = await prisma.protocol.findFirst({ where: { - hash: hash, active: true, }, - data: { - active: false, - }, }); - return { error: null, success: true }; - } - // Deactivate the current active protocol, if it exists - if (currentActive) { + // If input is false, deactivate the active protocol + if (!input) { + await prisma.protocol.update({ + where: { + hash: hash, + active: true, + }, + data: { + active: false, + }, + }); + return { error: null, success: true }; + } + + // Deactivate the current active protocol, if it exists + if (currentActive) { + await prisma.protocol.update({ + where: { + id: currentActive.id, + }, + data: { + active: false, + }, + }); + } + + // Make the protocol with the given hash active await prisma.protocol.update({ where: { - id: currentActive.id, + hash, }, data: { - active: false, + active: true, }, }); - } - // Make the protocol with the given hash active - await prisma.protocol.update({ - where: { - hash, - }, - data: { - active: true, - }, - }); - - return { error: null, success: true }; - } catch (error) { - return { error: 'Failed to set active protocol', success: false }; - } - }), + return { error: null, success: true }; + } catch (error) { + return { error: 'Failed to set active protocol', success: false }; + } + }), + }), delete: router({ all: protectedProcedure.mutation(async () => { - try { - const deletedProtocols = await prisma.protocol.deleteMany(); - return { error: null, deletedProtocols }; - } catch (error) { - return { - error: 'Failed to delete protocols', - deletedProtocols: null, - }; - } + const hashes = await prisma.protocol.findMany({ + select: { + hash: true, + }, + }); + return deleteProtocols(hashes.map((protocol) => protocol.hash)); }), byHash: protectedProcedure .input(z.array(z.string())) .mutation(async ({ input: hashes }) => { - try { - const deletedProtocols = await prisma.protocol.deleteMany({ - where: { hash: { in: hashes } }, - }); - return { error: null, deletedProtocols: deletedProtocols }; - } catch (error) { - return { - error: 'Failed to delete protocols', - deletedProtocols: null, - }; - } + return deleteProtocols(hashes); }), }), + insert: protectedProcedure + .input( + z.object({ + protocol: z.object({ + lastModified: z.string(), + schemaVersion: z.number(), + stages: z.array(z.any()), + codebook: z.record(z.any()), + description: z.string().optional(), + }), + protocolName: z.string(), + assets: z.array( + z.object({ + key: z.string(), + name: z.string(), + type: z.string(), + url: z.string(), + size: z.number(), + }), + ), + }), + ) + .mutation(async ({ input }) => { + const { protocol, protocolName, assets } = input; + try { + const protocolHash = await hash(JSON.stringify(protocol), 8); + + // eslint-disable-next-line local-rules/require-data-mapper + await prisma.protocol.create({ + data: { + hash: protocolHash, + lastModified: protocol.lastModified, + name: protocolName, + schemaVersion: protocol.schemaVersion, + stages: JSON.stringify(protocol.stages), + codebook: JSON.stringify(protocol.codebook), + description: protocol.description, + assets: { + create: assets, + }, + }, + }); + } catch (e) { + // Check for protocol already existing + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === 'P2002') { + return { error: 'Protocol already exists', success: false }; + } + + return { error: 'Error adding to database', success: false }; + } + + throw new Error('Error adding to database'); + } + }), }); diff --git a/utils/EventEmitter.ts b/utils/EventEmitter.ts new file mode 100644 index 00000000..a5276181 --- /dev/null +++ b/utils/EventEmitter.ts @@ -0,0 +1,27 @@ +import { EventEmitter as NodeEventEmitter } from 'events'; + +type EventMap = Record; + +type EventKey = string & keyof T; +type EventReceiver = (params: T) => void; + +interface Emitter { + on>(eventName: K, fn: EventReceiver): void; + off>(eventName: K, fn: EventReceiver): void; + emit>(eventName: K, params: T[K]): void; +} + +export class EventEmitter implements Emitter { + private emitter = new NodeEventEmitter(); + on>(eventName: K, fn: EventReceiver) { + this.emitter.on(eventName, fn as EventReceiver); + } + + off>(eventName: K, fn: EventReceiver) { + this.emitter.off(eventName, fn as EventReceiver); + } + + emit>(eventName: K, params: T[K]) { + this.emitter.emit(eventName, params); + } +} diff --git a/utils/protocolImport.tsx b/utils/protocolImport.tsx new file mode 100644 index 00000000..7cb147e7 --- /dev/null +++ b/utils/protocolImport.tsx @@ -0,0 +1,88 @@ +import type { AssetManifest, Protocol } from '@codaco/shared-consts'; +import type Zip from 'jszip'; + +export type FileEsque = Blob & { + name: string; +}; + +export const getProtocolJson = async (protocolZip: Zip) => { + const protocolString = await protocolZip + ?.file('protocol.json') + ?.async('string'); + + if (!protocolString) { + throw new Error('protocol.json not found in zip'); + } + + const protocolJson = (await JSON.parse(protocolString)) as Protocol; + + return protocolJson; +}; + +export const getAssets = async (protocolJson: Protocol, protocolZip: Zip) => { + const assetManifest = protocolJson?.assetManifest as AssetManifest; + + if (!assetManifest) { + return []; + } + + const data = new FormData(); + + await Promise.all( + Object.keys(assetManifest).map(async (key) => { + const asset = assetManifest[key]!; + + const file = (await protocolZip + ?.file(`assets/${asset.source}`) + ?.async('blob')) as FileEsque | null; + + if (!file) { + throw new Error('Asset not found in asset folder!'); + } + + file.name = asset.name; + + data.append('files', file, asset.source); + }), + ); + + const files = data.getAll('files') as File[]; + + return files; +}; + +export const fetchFileAsBuffer = async (url: string) => { + const res = await fetch(url); + const buffer = await res.arrayBuffer(); + + return buffer; +}; + +type ReadAs = 'arrayBuffer' | 'binaryString' | 'dataURL' | 'text'; + +export function readFileHelper( + file: Blob | File, + readAs: ReadAs = 'arrayBuffer', +): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener('error', (err) => { + reader.abort(); + reject(err); + }); + + reader.addEventListener('load', () => { + resolve(reader.result); + }); + + if (readAs === 'arrayBuffer') { + reader.readAsArrayBuffer(file); + } else if (readAs === 'binaryString') { + reader.readAsBinaryString(file); + } else if (readAs === 'dataURL') { + reader.readAsDataURL(file); + } else if (readAs === 'text') { + reader.readAsText(file, 'utf-8'); + } + }); +} diff --git a/utils/uploadthing.ts b/utils/uploadthing.ts deleted file mode 100644 index df2601a0..00000000 --- a/utils/uploadthing.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { generateComponents } from '@uploadthing/react'; - -import type { OurFileRouter } from '../app/api/uploadthing/core'; - -export const { UploadButton, UploadDropzone, Uploader } = - generateComponents(); diff --git a/utils/uploadthing/useEvent.ts b/utils/uploadthing/useEvent.ts deleted file mode 100644 index dc711e41..00000000 --- a/utils/uploadthing/useEvent.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Ripped from https://github.com/scottrippey/react-use-event-hook -import React from 'react'; - -type AnyFunction = (...args: any[]) => any; - -/** - * Suppress the warning when using useLayoutEffect with SSR. (https://reactjs.org/link/uselayouteffect-ssr) - * Make use of useInsertionEffect if available. - */ -const useInsertionEffect = - typeof window !== 'undefined' - ? // useInsertionEffect is available in React 18+ - React.useInsertionEffect || React.useLayoutEffect - : () => {}; - -/** - * Similar to useCallback, with a few subtle differences: - * - The returned function is a stable reference, and will always be the same between renders - * - No dependency lists required - * - Properties or state accessed within the callback will always be "current" - */ -export function useEvent( - callback: TCallback, -): TCallback { - // Keep track of the latest callback: - const latestRef = React.useRef( - useEvent_shouldNotBeInvokedBeforeMount as any, - ); - useInsertionEffect(() => { - latestRef.current = callback; - }, [callback]); - - // Create a stable callback that always calls the latest callback: - // using useRef instead of useCallback avoids creating and empty array on every render - const stableRef = React.useRef(null as any); - if (!stableRef.current) { - stableRef.current = function (this: any) { - return latestRef.current.apply(this, arguments as any); - } as TCallback; - } - - return stableRef.current; -} - -/** - * Render methods should be pure, especially when concurrency is used, - * so we will throw this error if the callback is called while rendering. - */ -function useEvent_shouldNotBeInvokedBeforeMount() { - throw new Error( - 'INVALID_USEEVENT_INVOCATION: the callback from useEvent cannot be invoked before the component has mounted.', - ); -} diff --git a/utils/uploadthing/useFetch.ts b/utils/uploadthing/useFetch.ts deleted file mode 100644 index 4b13d4be..00000000 --- a/utils/uploadthing/useFetch.ts +++ /dev/null @@ -1,90 +0,0 @@ -// Ripped from https://usehooks-ts.com/react-hook/use-fetch -import { useEffect, useReducer, useRef } from 'react'; - -interface State { - data?: T; - error?: Error; -} - -type Cache = { [url: string]: T }; - -// discriminated union type -type Action = - | { type: 'loading' } - | { type: 'fetched'; payload: T | undefined } - | { type: 'error'; payload: Error }; - -function useFetch(url?: string, options?: RequestInit): State { - const cache = useRef>({}); - - // Used to prevent state update if the component is unmounted - const cancelRequest = useRef(false); - - const initialState: State = { - error: undefined, - data: undefined, - }; - - // Keep state logic separated - const fetchReducer = (state: State, action: Action): State => { - switch (action.type) { - case 'loading': - return { ...initialState }; - case 'fetched': - return { ...initialState, data: action.payload }; - case 'error': - return { ...initialState, error: action.payload }; - default: - return state; - } - }; - - const [state, dispatch] = useReducer(fetchReducer, initialState); - - useEffect(() => { - // Do nothing if the url is not given - if (!url) return; - - cancelRequest.current = false; - - const fetchData = async () => { - dispatch({ type: 'loading' }); - - // If a cache exists for this url, return it - if (cache.current[url]) { - dispatch({ type: 'fetched', payload: cache.current[url] }); - return; - } - - try { - const response = await fetch(url, options); - if (!response.ok) { - throw new Error(response.statusText); - } - - const data = (await response.json()) as T; - cache.current[url] = data; - if (cancelRequest.current) return; - - dispatch({ type: 'fetched', payload: data }); - } catch (error) { - if (cancelRequest.current) return; - - dispatch({ type: 'error', payload: error as Error }); - } - }; - - void fetchData(); - - // Use the cleanup function for avoiding a possibly... - // ...state update after the component was unmounted - return () => { - cancelRequest.current = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [url]); - - return state; -} - -export default useFetch; From ff91a035f7ea5f7432583ea4104d9312be9cab0d Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Wed, 8 Nov 2023 17:52:51 +0200 Subject: [PATCH 03/15] implement initial error reporting --- .../_components/ProtocolUploader.tsx | 196 ++++++++++++------ app/(dashboard)/dashboard/protocols/page.tsx | 2 + components/ErrorAlert.tsx | 34 --- components/ErrorDetails.tsx | 9 + components/ui/AlertDialog.tsx | 2 +- components/ui/ErrorDialog.tsx | 2 +- components/ui/scroll-area.tsx | 53 +++++ package.json | 2 + pnpm-lock.yaml | 39 ++++ prisma/schema.prisma | 17 +- server/routers/protocol.ts | 34 ++- utils/databaseError.ts | 8 + utils/ensureError.ts | 19 ++ utils/protocolImport.tsx | 82 ++++---- 14 files changed, 345 insertions(+), 154 deletions(-) delete mode 100644 components/ErrorAlert.tsx create mode 100644 components/ErrorDetails.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 utils/databaseError.ts create mode 100644 utils/ensureError.ts diff --git a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx index 20fa45ae..8901ed9b 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx @@ -2,12 +2,16 @@ import { useState } from 'react'; import { useDropzone } from 'react-dropzone'; import { Button } from '~/components/ui/Button'; -import { Collapsible, CollapsibleContent } from '~/components/ui/collapsible'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '~/components/ui/collapsible'; import ActiveProtocolSwitch from '~/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch'; import { - getAssets, + getProtocolAssets, getProtocolJson, - readFileHelper, + fileAsArrayBuffer, } from '~/utils/protocolImport'; import ErrorDialog from '~/components/ui/ErrorDialog'; import { useToast } from '~/components/ui/use-toast'; @@ -15,6 +19,12 @@ import { Progress } from '~/components/ui/progress'; import { api } from '~/trpc/client'; import { uploadFiles } from '~/lib/uploadthing-helpers'; import { clientRevalidateTag } from '~/utils/clientRevalidate'; +import { useRouter } from 'next/navigation'; +import { DatabaseError } from '~/utils/databaseError'; +import { ensureError } from '~/utils/ensureError'; +import { ValidationError } from '@codaco/protocol-validation'; +import Link from 'next/link'; +import { ErrorDetails } from '~/components/ErrorDetails'; type ErrorState = { title: string; @@ -28,6 +38,7 @@ type ProgressState = { }; export default function ProtocolUploader() { + const router = useRouter(); const [error, setError] = useState(null); const [progress, setProgress] = useState(null); const { toast } = useToast(); @@ -49,19 +60,10 @@ export default function ProtocolUploader() { percent: 0, status: 'Reading file...', }); - const content = await readFileHelper(acceptedFile); - - if (!content) { - setError({ - title: 'Error reading file', - description: 'The file could not be read', - }); - setProgress(null); - return; - } - const JSZip = (await import('jszip')).default; - const zip = await JSZip.loadAsync(content); + const fileArrayBuffer = await fileAsArrayBuffer(acceptedFile); + const JSZip = (await import('jszip')).default; // Dynamic import to reduce bundle size + const zip = await JSZip.loadAsync(fileArrayBuffer); const protocolJson = await getProtocolJson(zip); // Validating protocol... @@ -70,44 +72,17 @@ export default function ProtocolUploader() { status: 'Validating protocol...', }); - const { validateProtocol, ValidationError } = await import( + const { validateProtocol } = await import( '@codaco/protocol-validation' ); - try { - await validateProtocol(protocolJson); - } catch (error) { - if (error instanceof ValidationError) { - setError({ - title: 'Protocol was invalid!', - description: 'The protocol you uploaded was invalid.', - additionalContent: ( - - -
-

Errors:

-
    - {error.logicErrors.map((e, i) => ( -
  • {e}
  • - ))} - {error.schemaErrors.map((e, i) => ( -
  • {e}
  • - ))} -
-
-
-
- ), - }); - setProgress(null); - return; - } + // This function will throw on validation errors, with type ValidationError + await validateProtocol(protocolJson); - throw error; - } + // After this point, assume the protocol is valid. + const assets = await getProtocolAssets(protocolJson, zip); - // Protocol is valid, continue with import - const assets = await getAssets(protocolJson, zip); + console.log('assets', assets); setProgress({ percent: 0, @@ -119,8 +94,8 @@ export default function ProtocolUploader() { const completeCount = assets.length * 100; let currentProgress = 0; - const response = await uploadFiles({ - files: assets, + const uploadedFiles = await uploadFiles({ + files: assets.map((asset) => asset.file), endpoint: 'assetRouter', onUploadProgress({ progress }) { currentProgress += progress; @@ -131,21 +106,43 @@ export default function ProtocolUploader() { }, }); - console.log('asset upload response', response); + // The asset 'name' prop matches across the assets array and the + // uploadedFiles array, so we can just map over one of them and + // merge the properties we need to add to the database. + const assetsWithUploadMeta = assets.map((asset) => { + const uploadedAsset = uploadedFiles.find( + (uploadedFile) => uploadedFile.name === asset.name, + ); + + if (!uploadedAsset) { + throw new Error('Asset upload failed'); + } + + return { + key: uploadedAsset.key, + assetId: asset.assetId, + name: asset.name, + type: asset.type, + url: uploadedAsset.url, + size: uploadedAsset.size, + }; + }); + + setProgress({ + percent: 100, + status: 'Creating database entry for protocol...', + }); - await insertProtocol({ + const result = await insertProtocol({ protocol: protocolJson, protocolName: fileName, - assets: response.map((fileResponse) => ({ - assetId: fileResponse.key, - key: fileResponse.key, - source: fileResponse.key, - url: fileResponse.url, - name: fileResponse.name, - size: fileResponse.size, - })), + assets: assetsWithUploadMeta, }); + if (result.error) { + throw new DatabaseError(result.error, result.errorDetails); + } + toast({ title: 'Protocol imported!', description: 'Your protocol has been successfully imported.', @@ -154,12 +151,83 @@ export default function ProtocolUploader() { setProgress(null); await clientRevalidateTag('protocol.get.all'); + router.refresh(); } catch (e) { + // eslint-disable-next-line no-console console.log(e); - setError({ - title: 'Error importing protocol', - description: e.message, - }); + + const error = ensureError(e); + + // Validation errors come from @codaco/protocol-validation + if (error instanceof ValidationError) { + setError({ + title: 'Protocol was invalid!', + description: ( + <> +

+ The protocol you uploaded was invalid. Please see the details + below for specific validation errors that were found. +

+

+ If you believe that your protocol should be valid please ask + for help via our{' '} + + community forum + + . +

+ + ), + additionalContent: ( + + <> +

{error.message}

+

Errors:

+
    + {error.logicErrors.map((e, i) => ( +
  • {e}
  • + ))} + {error.schemaErrors.map((e, i) => ( +
  • {e}
  • + ))} +
+ +
+ ), + }); + } + // Database errors are thrown inside our tRPC router + else if (error instanceof DatabaseError) { + setError({ + title: 'Database error during protocol import', + description: error.message, + additionalContent: ( + +
{error.originalError.toString()}
+
+ ), + }); + } else { + setError({ + title: 'Error importing protocol', + description: + 'There was an unknown error while importing your protocol. The information below might help us to debug the issue.', + additionalContent: ( + +
+                  Message: 
+                  {error.message}
+
+                  Stack: 
+                  {error.stack}
+                
+
+ ), + }); + } setProgress(null); } }, diff --git a/app/(dashboard)/dashboard/protocols/page.tsx b/app/(dashboard)/dashboard/protocols/page.tsx index dbf56fdb..f5a19220 100644 --- a/app/(dashboard)/dashboard/protocols/page.tsx +++ b/app/(dashboard)/dashboard/protocols/page.tsx @@ -2,6 +2,8 @@ import ProtocolUploader from '../_components/ProtocolUploader'; import { ProtocolsTable } from '../_components/ProtocolsTable/ProtocolsTable'; import { api } from '~/trpc/server'; +export const dynamic = 'force-dynamic'; + const ProtocolsPage = async () => { const protocols = await api.protocol.get.all.query(); return ( diff --git a/components/ErrorAlert.tsx b/components/ErrorAlert.tsx deleted file mode 100644 index a2b41921..00000000 --- a/components/ErrorAlert.tsx +++ /dev/null @@ -1,34 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { Button } from '~/components/ui/Button'; -import { AlertTriangle } from 'lucide-react'; - -export default function ErrorAlert({ - error, - reset, - heading, -}: { - error: Error; - reset: () => void; - heading?: string; -}) { - useEffect(() => { - // Log the error to an error reporting service - // eslint-disable-next-line no-console - console.error(error); - }, [error]); - - return ( -
- -

- {heading || 'Something went wrong'} -

-

{error.message}

-
- -
-
- ); -} diff --git a/components/ErrorDetails.tsx b/components/ErrorDetails.tsx new file mode 100644 index 00000000..66a4f777 --- /dev/null +++ b/components/ErrorDetails.tsx @@ -0,0 +1,9 @@ +import type { PropsWithChildren } from 'react'; + +export const ErrorDetails = (props: PropsWithChildren) => { + return ( +
+ {props.children} +
+ ); +}; diff --git a/components/ui/AlertDialog.tsx b/components/ui/AlertDialog.tsx index ade494f0..4cf3dc01 100644 --- a/components/ui/AlertDialog.tsx +++ b/components/ui/AlertDialog.tsx @@ -97,7 +97,7 @@ const AlertDialogDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/components/ui/ErrorDialog.tsx b/components/ui/ErrorDialog.tsx index a1da9fe8..8227b506 100644 --- a/components/ui/ErrorDialog.tsx +++ b/components/ui/ErrorDialog.tsx @@ -37,8 +37,8 @@ const ErrorDialog = ({ {description && ( {description} )} - {additionalContent} + {additionalContent} {confirmLabel} diff --git a/components/ui/scroll-area.tsx b/components/ui/scroll-area.tsx new file mode 100644 index 00000000..add48852 --- /dev/null +++ b/components/ui/scroll-area.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "~/utils/shadcn" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/package.json b/package.json index a863c71b..9480d4fd 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", @@ -64,6 +65,7 @@ "lucide-react": "^0.286.0", "next": "^14.0.0", "next-usequerystate": "^1.8.4", + "ohash": "^1.1.3", "papaparse": "^5.4.1", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad120ef6..f3b4efbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ dependencies: '@radix-ui/react-progress': specifier: ^1.0.3 version: 1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-scroll-area': + specifier: ^1.0.5 + version: 1.0.5(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-select': specifier: ^2.0.0 version: 2.0.0(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0) @@ -140,6 +143,9 @@ dependencies: next-usequerystate: specifier: ^1.8.4 version: 1.8.4(next@14.0.0) + ohash: + specifier: ^1.1.3 + version: 1.1.3 papaparse: specifier: ^5.4.1 version: 5.4.1 @@ -3169,6 +3175,35 @@ packages: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + /@radix-ui/react-scroll-area@1.0.5(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-b6PAgH4GQf9QEn8zbT2XUHpW5z8BzqEc7Kl11TwDrvuTrxlkcjTD5qa/bxgKr+nmuXKu4L/W5UZ4mlP/VG/5Gw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.2 + '@radix-ui/number': 1.0.1 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.33)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.33)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.33)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.33)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.33)(react@18.2.0) + '@types/react': 18.2.33 + '@types/react-dom': 18.2.14 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-select@1.2.2(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-zI7McXr8fNaSrUY9mZe4x/HC0jTLY9fWNhO1oLWYMQGDXuV4UCivIGTxwioSzO0ZCYX9iSLyWmAh/1TOmX3Cnw==} peerDependencies: @@ -10627,6 +10662,10 @@ packages: resolution: {integrity: sha512-eJJDYkhJFFbBBAxeh8xW+weHlkI28n2ZdQV/J/DNfWfSKlGEf2xcfAbZTv3riEXHAhL9SVOTs2pRmXiSTf78xg==} dev: true + /ohash@1.1.3: + resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==} + dev: false + /on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c399b75f..ca19a179 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,17 +28,16 @@ model Protocol { } model Asset { - id String @id @default(cuid()) - name String // from upload thing - source String // from manifest - type String // from manifest - key String // from upload thing - url String // from upload thing - size Int // from upload thing + key String @id @unique + assetId String @unique + name String + type String + url String + size Int protocol Protocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) protocolId String // from db - @@index([protocolId]) + @@index(fields: [protocolId, assetId, key]) } model Interview { @@ -54,7 +53,7 @@ model Interview { protocolId String @map("protocolId") currentStep Int @default(0) - @@index([protocolId]) + @@index(fields: [protocolId]) } model Participant { diff --git a/server/routers/protocol.ts b/server/routers/protocol.ts index 98b04311..0aee461a 100644 --- a/server/routers/protocol.ts +++ b/server/routers/protocol.ts @@ -2,7 +2,7 @@ import { prisma } from '~/utils/db'; import { protectedProcedure, router } from '~/server/trpc'; import { z } from 'zod'; -import { hash } from 'bcrypt'; +import { hash } from 'ohash'; import { Prisma } from '@prisma/client'; import { utapi } from '~/app/api/uploadthing/core'; @@ -29,6 +29,12 @@ export const deleteProtocols = async (hashes: string[]) => { select: { key: true }, }); + if (assets.length === 0) { + // eslint-disable-next-line no-console + console.log('No assets to delete'); + return; + } + await deleteFilesFromUploadThing(assets.map((a) => a.key)); } catch (error) { // eslint-disable-next-line no-console @@ -190,6 +196,7 @@ export const protocolRouter = router({ assets: z.array( z.object({ key: z.string(), + assetId: z.string(), name: z.string(), type: z.string(), url: z.string(), @@ -201,7 +208,7 @@ export const protocolRouter = router({ .mutation(async ({ input }) => { const { protocol, protocolName, assets } = input; try { - const protocolHash = await hash(JSON.stringify(protocol), 8); + const protocolHash = hash(protocol); // eslint-disable-next-line local-rules/require-data-mapper await prisma.protocol.create({ @@ -210,25 +217,38 @@ export const protocolRouter = router({ lastModified: protocol.lastModified, name: protocolName, schemaVersion: protocol.schemaVersion, - stages: JSON.stringify(protocol.stages), - codebook: JSON.stringify(protocol.codebook), + stages: protocol.stages, + codebook: protocol.codebook, description: protocol.description, assets: { create: assets, }, }, }); + + return { error: null, success: true }; } catch (e) { + await deleteFilesFromUploadThing(assets.map((a) => a.key)); // Check for protocol already existing if (e instanceof Prisma.PrismaClientKnownRequestError) { if (e.code === 'P2002') { - return { error: 'Protocol already exists', success: false }; + return { + error: + 'The protocol you attempted to add already exists in the database. Please remove it and try again.', + success: false, + errorDetails: e, + }; } - return { error: 'Error adding to database', success: false }; + return { + error: + 'There was an error adding your protocol to the database. See the error details for more information.', + success: false, + errorDetails: e, + }; } - throw new Error('Error adding to database'); + throw e; } }), }); diff --git a/utils/databaseError.ts b/utils/databaseError.ts new file mode 100644 index 00000000..342c938d --- /dev/null +++ b/utils/databaseError.ts @@ -0,0 +1,8 @@ +export class DatabaseError extends Error { + constructor( + message: string, + public readonly originalError: Error, + ) { + super(message); + } +} diff --git a/utils/ensureError.ts b/utils/ensureError.ts new file mode 100644 index 00000000..e65c871d --- /dev/null +++ b/utils/ensureError.ts @@ -0,0 +1,19 @@ +// Helper function that ensures that a value is an Error +export function ensureError(value: unknown): Error { + if (!value) return new Error('No value was thrown'); + + if (value instanceof Error) return value; + + // Test if value inherits from Error + if (value.isPrototypeOf(Error)) return value as Error & typeof value; + + let stringified = '[Unable to stringify the thrown value]'; + try { + stringified = JSON.stringify(value); + } catch {} + + const error = new Error( + `This value was thrown as is, not through an Error: ${stringified}`, + ); + return error; +} diff --git a/utils/protocolImport.tsx b/utils/protocolImport.tsx index 7cb147e7..a099272f 100644 --- a/utils/protocolImport.tsx +++ b/utils/protocolImport.tsx @@ -1,10 +1,6 @@ import type { AssetManifest, Protocol } from '@codaco/shared-consts'; import type Zip from 'jszip'; -export type FileEsque = Blob & { - name: string; -}; - export const getProtocolJson = async (protocolZip: Zip) => { const protocolString = await protocolZip ?.file('protocol.json') @@ -19,70 +15,80 @@ export const getProtocolJson = async (protocolZip: Zip) => { return protocolJson; }; -export const getAssets = async (protocolJson: Protocol, protocolZip: Zip) => { +type ProtocolAsset = { + assetId: string; + name: string; + type: string; + file: File; +}; + +export const getProtocolAssets = async ( + protocolJson: Protocol, + protocolZip: Zip, +) => { const assetManifest = protocolJson?.assetManifest as AssetManifest; if (!assetManifest) { return []; } - const data = new FormData(); + /** + * Structure of an Asset: + * - Asset is an object. Key is the UID. + * - ID property is the same as the key. + * - Name property is the original file name when added to Architect + * - Source property is the internal path to the file in the zip, which is a + * separate UID + file extension. + * - The type property is one of the NC asset types (e.g. 'image', 'video', etc.) + */ + + const files: ProtocolAsset[] = []; await Promise.all( Object.keys(assetManifest).map(async (key) => { const asset = assetManifest[key]!; - const file = (await protocolZip + const file = await protocolZip ?.file(`assets/${asset.source}`) - ?.async('blob')) as FileEsque | null; + ?.async('blob'); if (!file) { - throw new Error('Asset not found in asset folder!'); + throw new Error( + `Asset "${asset.source}" was not found in asset folder!`, + ); } - file.name = asset.name; - - data.append('files', file, asset.source); + // data.append('files', file, asset.source); + files.push({ + assetId: key, + name: asset.source, + type: asset.type, + file: new File([file], asset.source), + }); }), ); - const files = data.getAll('files') as File[]; - return files; }; -export const fetchFileAsBuffer = async (url: string) => { - const res = await fetch(url); - const buffer = await res.arrayBuffer(); - - return buffer; -}; - -type ReadAs = 'arrayBuffer' | 'binaryString' | 'dataURL' | 'text'; - -export function readFileHelper( - file: Blob | File, - readAs: ReadAs = 'arrayBuffer', -): Promise { - return new Promise((resolve, reject) => { +export function fileAsArrayBuffer(file: Blob | File): Promise { + return new Promise((resolve) => { const reader = new FileReader(); reader.addEventListener('error', (err) => { reader.abort(); - reject(err); + // eslint-disable-next-line no-console + console.log('readFileHelper Error: ', err); + throw new Error('The file could not be read.'); }); reader.addEventListener('load', () => { + if (!reader.result || typeof reader.result === 'string') { + throw new Error('The file could not be read.'); + } + resolve(reader.result); }); - if (readAs === 'arrayBuffer') { - reader.readAsArrayBuffer(file); - } else if (readAs === 'binaryString') { - reader.readAsBinaryString(file); - } else if (readAs === 'dataURL') { - reader.readAsDataURL(file); - } else if (readAs === 'text') { - reader.readAsText(file, 'utf-8'); - } + reader.readAsArrayBuffer(file); }); } From 015245ea472a3892efe455d791fddfd887f18aaf Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Wed, 8 Nov 2023 21:20:12 +0200 Subject: [PATCH 04/15] update protocol validation return types for better code structure --- .../_components/ProtocolUploader.tsx | 123 ++++++++++-------- .../ProtocolsTable/ProtocolsTable.tsx | 16 +-- .../dashboard/_components/UserMenu.tsx | 8 +- components/ErrorDetails.tsx | 2 +- components/ui/ErrorDialog.tsx | 4 +- package.json | 2 +- pnpm-lock.yaml | 8 +- 7 files changed, 81 insertions(+), 82 deletions(-) diff --git a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx index 8901ed9b..34883666 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx @@ -23,8 +23,10 @@ import { useRouter } from 'next/navigation'; import { DatabaseError } from '~/utils/databaseError'; import { ensureError } from '~/utils/ensureError'; import { ValidationError } from '@codaco/protocol-validation'; -import Link from 'next/link'; import { ErrorDetails } from '~/components/ErrorDetails'; +import { XCircle } from 'lucide-react'; +import Link from '~/components/Link'; +import { AlertDescription } from '~/components/ui/Alert'; type ErrorState = { title: string; @@ -39,6 +41,7 @@ type ProgressState = { export default function ProtocolUploader() { const router = useRouter(); + const utils = api.useUtils(); const [error, setError] = useState(null); const [progress, setProgress] = useState(null); const { toast } = useToast(); @@ -77,13 +80,62 @@ export default function ProtocolUploader() { ); // This function will throw on validation errors, with type ValidationError - await validateProtocol(protocolJson); + const validationResult = await validateProtocol(protocolJson); + + if (!validationResult.isValid) { + // eslint-disable-next-line no-console + console.log('validationResult', validationResult); + + setError({ + title: 'The protocol is invalid!', + description: ( + <> + + The protocol you uploaded is invalid. See the details below + for specific validation errors that were found. + + + If you believe that your protocol should be valid please ask + for help via our{' '} + + community forum + + . + + + ), + additionalContent: ( + +
    + {[ + ...validationResult.schemaErrors, + ...validationResult.logicErrors, + ].map((validationError, i) => ( +
  • + + + {validationError.message}{' '} + + ({validationError.path}) + + +
  • + ))} +
+
+ ), + }); + + setProgress(null); + return; + } // After this point, assume the protocol is valid. const assets = await getProtocolAssets(protocolJson, zip); - console.log('assets', assets); - setProgress({ percent: 0, status: 'Uploading assets...', @@ -151,59 +203,18 @@ export default function ProtocolUploader() { setProgress(null); await clientRevalidateTag('protocol.get.all'); + await utils.protocol.get.all.invalidate(); router.refresh(); } catch (e) { // eslint-disable-next-line no-console console.log(e); const error = ensureError(e); - - // Validation errors come from @codaco/protocol-validation - if (error instanceof ValidationError) { - setError({ - title: 'Protocol was invalid!', - description: ( - <> -

- The protocol you uploaded was invalid. Please see the details - below for specific validation errors that were found. -

-

- If you believe that your protocol should be valid please ask - for help via our{' '} - - community forum - - . -

- - ), - additionalContent: ( - - <> -

{error.message}

-

Errors:

-
    - {error.logicErrors.map((e, i) => ( -
  • {e}
  • - ))} - {error.schemaErrors.map((e, i) => ( -
  • {e}
  • - ))} -
- -
- ), - }); - } // Database errors are thrown inside our tRPC router - else if (error instanceof DatabaseError) { + if (error instanceof DatabaseError) { setError({ title: 'Database error during protocol import', - description: error.message, + description: {error.message}, additionalContent: (
{error.originalError.toString()}
@@ -213,17 +224,15 @@ export default function ProtocolUploader() { } else { setError({ title: 'Error importing protocol', - description: - 'There was an unknown error while importing your protocol. The information below might help us to debug the issue.', + description: ( + + There was an unknown error while importing your protocol. The + information below might help us to debug the issue. + + ), additionalContent: ( -
-                  Message: 
-                  {error.message}
-
-                  Stack: 
-                  {error.stack}
-                
+
{error.message}
), }); diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx index 4919f093..d86be3fd 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx @@ -13,16 +13,13 @@ export const ProtocolsTable = ({ }: { initialData: ProtocolWithInterviews[]; }) => { - const { isLoading, data: protocols } = api.protocol.get.all.useQuery( - undefined, - { - initialData, - refetchOnMount: false, - onError(error) { - throw new Error(error.message); - }, + const { data: protocols } = api.protocol.get.all.useQuery(undefined, { + initialData, + refetchOnMount: false, + onError(error) { + throw new Error(error.message); }, - ); + }); const [showAlertDialog, setShowAlertDialog] = useState(false); const [protocolsToDelete, setProtocolsToDelete] = @@ -35,7 +32,6 @@ export const ProtocolsTable = ({ return ( <> - {isLoading &&
Loading...
} { - const { signOut, isLoading } = useSession(); + const { signOut } = useSession(); return (
- +
); }; diff --git a/components/ErrorDetails.tsx b/components/ErrorDetails.tsx index 66a4f777..acd15bc3 100644 --- a/components/ErrorDetails.tsx +++ b/components/ErrorDetails.tsx @@ -2,7 +2,7 @@ import type { PropsWithChildren } from 'react'; export const ErrorDetails = (props: PropsWithChildren) => { return ( -
+
{props.children}
); diff --git a/components/ui/ErrorDialog.tsx b/components/ui/ErrorDialog.tsx index 8227b506..ea952663 100644 --- a/components/ui/ErrorDialog.tsx +++ b/components/ui/ErrorDialog.tsx @@ -34,9 +34,7 @@ const ErrorDialog = ({ {title} - {description && ( - {description} - )} + {description} {additionalContent} diff --git a/package.json b/package.json index 9480d4fd..47eb4d66 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "seed": "tsx prisma/seed.ts" }, "dependencies": { - "@codaco/protocol-validation": "3.0.0-alpha.2", + "@codaco/protocol-validation": "3.0.0-alpha.4", "@codaco/shared-consts": "^0.0.2", "@headlessui/react": "^1.7.17", "@hookform/resolvers": "^3.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3b4efbd..d0540da5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: dependencies: '@codaco/protocol-validation': - specifier: 3.0.0-alpha.2 - version: 3.0.0-alpha.2(@types/eslint@8.44.6)(eslint-config-prettier@9.0.0)(eslint@8.52.0) + specifier: 3.0.0-alpha.4 + version: 3.0.0-alpha.4(@types/eslint@8.44.6)(eslint-config-prettier@9.0.0)(eslint@8.52.0) '@codaco/shared-consts': specifier: ^0.0.2 version: 0.0.2 @@ -1683,8 +1683,8 @@ packages: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true - /@codaco/protocol-validation@3.0.0-alpha.2(@types/eslint@8.44.6)(eslint-config-prettier@9.0.0)(eslint@8.52.0): - resolution: {integrity: sha512-8eOC5sUiTl8sJhOZ2PsQ5exiogcx5oYKRb6epI28pA9dr2uXpYCiZ/P5CnZJJysDc4hzOoJddbR4L/3jFQNnCQ==} + /@codaco/protocol-validation@3.0.0-alpha.4(@types/eslint@8.44.6)(eslint-config-prettier@9.0.0)(eslint@8.52.0): + resolution: {integrity: sha512-2vV5Tga/zco/vrdDwkmirkiEqvwq+pduV4U4bHJhIgivxFSxswy6Ae7VLsbgOsFRaypx/SMrh7kF9wmU30liLQ==} dependencies: '@codaco/shared-consts': 0.0.2 '@types/lodash-es': 4.17.10 From 2dbe81d8f52f2adf01f681b55d3f80cbf91dbc0a Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Wed, 8 Nov 2023 21:33:52 +0200 Subject: [PATCH 05/15] refactor to use custom hook --- .../_components/ProtocolUploader.tsx | 200 +--------------- hooks/useProtocolImport.tsx | 225 ++++++++++++++++++ 2 files changed, 232 insertions(+), 193 deletions(-) create mode 100644 hooks/useProtocolImport.tsx diff --git a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx index 34883666..96cca177 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx @@ -27,217 +27,31 @@ import { ErrorDetails } from '~/components/ErrorDetails'; import { XCircle } from 'lucide-react'; import Link from '~/components/Link'; import { AlertDescription } from '~/components/ui/Alert'; - -type ErrorState = { - title: string; - description: React.ReactNode; - additionalContent?: React.ReactNode; -}; - -type ProgressState = { - percent: number; - status: string; -}; +import { useProtocolImport } from '~/hooks/useProtocolImport'; export default function ProtocolUploader() { + const { error, progress, reset, uploadProtocol } = useProtocolImport(); + const router = useRouter(); const utils = api.useUtils(); - const [error, setError] = useState(null); - const [progress, setProgress] = useState(null); const { toast } = useToast(); - const { mutateAsync: insertProtocol } = api.protocol.insert.useMutation(); const { getRootProps, getInputProps } = useDropzone({ multiple: false, onDropAccepted: async (acceptedFiles) => { - try { - setProgress({ - percent: 0, - status: 'Processing...', - }); - - const acceptedFile = acceptedFiles[0] as File; - const fileName = acceptedFile.name; - - setProgress({ - percent: 0, - status: 'Reading file...', - }); - - const fileArrayBuffer = await fileAsArrayBuffer(acceptedFile); - const JSZip = (await import('jszip')).default; // Dynamic import to reduce bundle size - const zip = await JSZip.loadAsync(fileArrayBuffer); - const protocolJson = await getProtocolJson(zip); - - // Validating protocol... - setProgress({ - percent: 0, - status: 'Validating protocol...', - }); - - const { validateProtocol } = await import( - '@codaco/protocol-validation' - ); - - // This function will throw on validation errors, with type ValidationError - const validationResult = await validateProtocol(protocolJson); - - if (!validationResult.isValid) { - // eslint-disable-next-line no-console - console.log('validationResult', validationResult); - - setError({ - title: 'The protocol is invalid!', - description: ( - <> - - The protocol you uploaded is invalid. See the details below - for specific validation errors that were found. - - - If you believe that your protocol should be valid please ask - for help via our{' '} - - community forum - - . - - - ), - additionalContent: ( - -
    - {[ - ...validationResult.schemaErrors, - ...validationResult.logicErrors, - ].map((validationError, i) => ( -
  • - - - {validationError.message}{' '} - - ({validationError.path}) - - -
  • - ))} -
-
- ), - }); - - setProgress(null); - return; - } - - // After this point, assume the protocol is valid. - const assets = await getProtocolAssets(protocolJson, zip); - - setProgress({ - percent: 0, - status: 'Uploading assets...', - }); - - // Calculate overall asset upload progress by summing the progress - // of each asset, then dividing by the total number of assets * 100. - const completeCount = assets.length * 100; - let currentProgress = 0; - - const uploadedFiles = await uploadFiles({ - files: assets.map((asset) => asset.file), - endpoint: 'assetRouter', - onUploadProgress({ progress }) { - currentProgress += progress; - setProgress({ - percent: Math.round((currentProgress / completeCount) * 100), - status: 'Uploading assets...', - }); - }, - }); - - // The asset 'name' prop matches across the assets array and the - // uploadedFiles array, so we can just map over one of them and - // merge the properties we need to add to the database. - const assetsWithUploadMeta = assets.map((asset) => { - const uploadedAsset = uploadedFiles.find( - (uploadedFile) => uploadedFile.name === asset.name, - ); - - if (!uploadedAsset) { - throw new Error('Asset upload failed'); - } - - return { - key: uploadedAsset.key, - assetId: asset.assetId, - name: asset.name, - type: asset.type, - url: uploadedAsset.url, - size: uploadedAsset.size, - }; - }); - - setProgress({ - percent: 100, - status: 'Creating database entry for protocol...', - }); - - const result = await insertProtocol({ - protocol: protocolJson, - protocolName: fileName, - assets: assetsWithUploadMeta, - }); - - if (result.error) { - throw new DatabaseError(result.error, result.errorDetails); - } + const { success } = await uploadProtocol(acceptedFiles[0]); + if (success) { toast({ title: 'Protocol imported!', description: 'Your protocol has been successfully imported.', variant: 'success', }); - setProgress(null); + reset(); await clientRevalidateTag('protocol.get.all'); await utils.protocol.get.all.invalidate(); router.refresh(); - } catch (e) { - // eslint-disable-next-line no-console - console.log(e); - - const error = ensureError(e); - // Database errors are thrown inside our tRPC router - if (error instanceof DatabaseError) { - setError({ - title: 'Database error during protocol import', - description: {error.message}, - additionalContent: ( - -
{error.originalError.toString()}
-
- ), - }); - } else { - setError({ - title: 'Error importing protocol', - description: ( - - There was an unknown error while importing your protocol. The - information below might help us to debug the issue. - - ), - additionalContent: ( - -
{error.message}
-
- ), - }); - } - setProgress(null); } }, accept: { @@ -250,7 +64,7 @@ export default function ProtocolUploader() { <> setError(null)} + onOpenChange={reset} title={error?.title} description={error?.description} additionalContent={error?.additionalContent} diff --git a/hooks/useProtocolImport.tsx b/hooks/useProtocolImport.tsx new file mode 100644 index 00000000..1f931aa1 --- /dev/null +++ b/hooks/useProtocolImport.tsx @@ -0,0 +1,225 @@ +import { XCircle } from 'lucide-react'; +import { useState } from 'react'; +import { ErrorDetails } from '~/components/ErrorDetails'; +import Link from '~/components/Link'; +import { AlertDescription } from '~/components/ui/Alert'; +import { uploadFiles } from '~/lib/uploadthing-helpers'; +import { api } from '~/trpc/client'; +import { DatabaseError } from '~/utils/databaseError'; +import { ensureError } from '~/utils/ensureError'; +import { + fileAsArrayBuffer, + getProtocolJson, + getProtocolAssets, +} from '~/utils/protocolImport'; + +type ErrorState = { + title: string; + description: React.ReactNode; + additionalContent?: React.ReactNode; +}; + +type ProgressState = { + percent: number; + status: string; +}; + +export const useProtocolImport = () => { + const [error, setError] = useState(null); + const [progress, setProgress] = useState(null); + const { mutateAsync: insertProtocol } = api.protocol.insert.useMutation(); + + const uploadProtocol = async (protocolFile: File) => { + try { + setProgress({ + percent: 0, + status: 'Processing...', + }); + + const fileName = protocolFile.name; + + setProgress({ + percent: 0, + status: 'Reading file...', + }); + + const fileArrayBuffer = await fileAsArrayBuffer(protocolFile); + const JSZip = (await import('jszip')).default; // Dynamic import to reduce bundle size + const zip = await JSZip.loadAsync(fileArrayBuffer); + const protocolJson = await getProtocolJson(zip); + + // Validating protocol... + setProgress({ + percent: 0, + status: 'Validating protocol...', + }); + + const { validateProtocol } = await import('@codaco/protocol-validation'); + + // This function will throw on validation errors, with type ValidationError + const validationResult = await validateProtocol(protocolJson); + + if (!validationResult.isValid) { + // eslint-disable-next-line no-console + console.log('validationResult', validationResult); + + setError({ + title: 'The protocol is invalid!', + description: ( + <> + + The protocol you uploaded is invalid. See the details below for + specific validation errors that were found. + + + If you believe that your protocol should be valid please ask for + help via our{' '} + + community forum + + . + + + ), + additionalContent: ( + +
    + {[ + ...validationResult.schemaErrors, + ...validationResult.logicErrors, + ].map((validationError, i) => ( +
  • + + + {validationError.message}{' '} + + ({validationError.path}) + + +
  • + ))} +
+
+ ), + }); + + setProgress(null); + return { success: false }; + } + + // After this point, assume the protocol is valid. + const assets = await getProtocolAssets(protocolJson, zip); + + setProgress({ + percent: 0, + status: 'Uploading assets...', + }); + + // Calculate overall asset upload progress by summing the progress + // of each asset, then dividing by the total number of assets * 100. + const completeCount = assets.length * 100; + let currentProgress = 0; + + const uploadedFiles = await uploadFiles({ + files: assets.map((asset) => asset.file), + endpoint: 'assetRouter', + onUploadProgress({ progress }) { + currentProgress += progress; + setProgress({ + percent: Math.round((currentProgress / completeCount) * 100), + status: 'Uploading assets...', + }); + }, + }); + + // The asset 'name' prop matches across the assets array and the + // uploadedFiles array, so we can just map over one of them and + // merge the properties we need to add to the database. + const assetsWithUploadMeta = assets.map((asset) => { + const uploadedAsset = uploadedFiles.find( + (uploadedFile) => uploadedFile.name === asset.name, + ); + + if (!uploadedAsset) { + throw new Error('Asset upload failed'); + } + + return { + key: uploadedAsset.key, + assetId: asset.assetId, + name: asset.name, + type: asset.type, + url: uploadedAsset.url, + size: uploadedAsset.size, + }; + }); + + setProgress({ + percent: 100, + status: 'Creating database entry for protocol...', + }); + + const result = await insertProtocol({ + protocol: protocolJson, + protocolName: fileName, + assets: assetsWithUploadMeta, + }); + + if (result.error) { + throw new DatabaseError(result.error, result.errorDetails); + } + + return { success: true }; + } catch (e) { + // eslint-disable-next-line no-console + console.log(e); + + const error = ensureError(e); + // Database errors are thrown inside our tRPC router + if (error instanceof DatabaseError) { + setError({ + title: 'Database error during protocol import', + description: {error.message}, + additionalContent: ( + +
{error.originalError.toString()}
+
+ ), + }); + } else { + setError({ + title: 'Error importing protocol', + description: ( + + There was an unknown error while importing your protocol. The + information below might help us to debug the issue. + + ), + additionalContent: ( + +
{error.message}
+
+ ), + }); + } + setProgress(null); + + return { success: false }; + } + }; + + const reset = () => { + setError(null); + setProgress(null); + }; + + return { + error, + progress, + reset, + uploadProtocol, + }; +}; From f4d8e03dae579fba5f951055acb7a3affdf727c8 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Wed, 8 Nov 2023 21:43:44 +0200 Subject: [PATCH 06/15] increase maxFileCount to 50 --- app/api/uploadthing/core.ts | 2 +- hooks/useProtocolImport.tsx | 17 ++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/app/api/uploadthing/core.ts b/app/api/uploadthing/core.ts index 01bded80..283473f5 100644 --- a/app/api/uploadthing/core.ts +++ b/app/api/uploadthing/core.ts @@ -7,7 +7,7 @@ const f = createUploadthing(); // FileRouter for your app, can contain multiple FileRoutes export const ourFileRouter = { assetRouter: f({ - blob: { maxFileSize: '256MB', maxFileCount: 10 }, + blob: { maxFileSize: '256MB', maxFileCount: 50 }, }) .middleware(async () => { const session = await getServerSession(); diff --git a/hooks/useProtocolImport.tsx b/hooks/useProtocolImport.tsx index 1f931aa1..6e558429 100644 --- a/hooks/useProtocolImport.tsx +++ b/hooks/useProtocolImport.tsx @@ -38,11 +38,6 @@ export const useProtocolImport = () => { const fileName = protocolFile.name; - setProgress({ - percent: 0, - status: 'Reading file...', - }); - const fileArrayBuffer = await fileAsArrayBuffer(protocolFile); const JSZip = (await import('jszip')).default; // Dynamic import to reduce bundle size const zip = await JSZip.loadAsync(fileArrayBuffer); @@ -50,7 +45,7 @@ export const useProtocolImport = () => { // Validating protocol... setProgress({ - percent: 0, + percent: 5, status: 'Validating protocol...', }); @@ -111,17 +106,17 @@ export const useProtocolImport = () => { } // After this point, assume the protocol is valid. - const assets = await getProtocolAssets(protocolJson, zip); - setProgress({ - percent: 0, + percent: 20, status: 'Uploading assets...', }); + const assets = await getProtocolAssets(protocolJson, zip); + // Calculate overall asset upload progress by summing the progress // of each asset, then dividing by the total number of assets * 100. - const completeCount = assets.length * 100; - let currentProgress = 0; + const completeCount = assets.length * 100 + 20; + let currentProgress = 20; const uploadedFiles = await uploadFiles({ files: assets.map((asset) => asset.file), From 5c76f9129c6c30de9953f0a4e5bc03ee8e026992 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Wed, 8 Nov 2023 22:39:11 +0200 Subject: [PATCH 07/15] add animations --- .../_components/ProtocolUploader.tsx | 109 +++++++++++------- app/api/uploadthing/core.ts | 7 +- hooks/useProtocolImport.tsx | 6 +- 3 files changed, 74 insertions(+), 48 deletions(-) diff --git a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx index 96cca177..9c0110b4 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx @@ -1,33 +1,24 @@ 'use client'; -import { useState } from 'react'; + import { useDropzone } from 'react-dropzone'; import { Button } from '~/components/ui/Button'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '~/components/ui/collapsible'; import ActiveProtocolSwitch from '~/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch'; -import { - getProtocolAssets, - getProtocolJson, - fileAsArrayBuffer, -} from '~/utils/protocolImport'; import ErrorDialog from '~/components/ui/ErrorDialog'; import { useToast } from '~/components/ui/use-toast'; import { Progress } from '~/components/ui/progress'; import { api } from '~/trpc/client'; -import { uploadFiles } from '~/lib/uploadthing-helpers'; import { clientRevalidateTag } from '~/utils/clientRevalidate'; import { useRouter } from 'next/navigation'; -import { DatabaseError } from '~/utils/databaseError'; -import { ensureError } from '~/utils/ensureError'; -import { ValidationError } from '@codaco/protocol-validation'; -import { ErrorDetails } from '~/components/ErrorDetails'; -import { XCircle } from 'lucide-react'; -import Link from '~/components/Link'; -import { AlertDescription } from '~/components/ui/Alert'; import { useProtocolImport } from '~/hooks/useProtocolImport'; +import { FileUp, Loader2 } from 'lucide-react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useState } from 'react'; + +const variants = { + initial: { opacity: 0, y: 20 }, + enter: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -20 }, +}; export default function ProtocolUploader() { const { error, progress, reset, uploadProtocol } = useProtocolImport(); @@ -37,9 +28,11 @@ export default function ProtocolUploader() { const { toast } = useToast(); const { getRootProps, getInputProps } = useDropzone({ + disabled: !!progress, multiple: false, onDropAccepted: async (acceptedFiles) => { - const { success } = await uploadProtocol(acceptedFiles[0]); + const file = acceptedFiles[0] as File; + const { success } = await uploadProtocol(file); if (success) { toast({ @@ -69,25 +62,63 @@ export default function ProtocolUploader() { description={error?.description} additionalContent={error?.additionalContent} /> - {progress ? ( - <> -

{progress.status}

- -
- - - ) : ( -
- -
Click to select .netcanvas file or drag and drop here
-
- )} +
+ + {progress && ( + + +
+
+ + + {progress.status} + + +
+ +
+
+ )} + + {!progress && ( + + +

+ Click to select .netcanvas file or drag and drop + here +

+
+ )} +
+
); } diff --git a/app/api/uploadthing/core.ts b/app/api/uploadthing/core.ts index 283473f5..642181d6 100644 --- a/app/api/uploadthing/core.ts +++ b/app/api/uploadthing/core.ts @@ -16,12 +16,7 @@ export const ourFileRouter = { } return {}; }) - .onUploadError((error) => { - console.log('assetRouter onUploadError', error); - }) - .onUploadComplete((file) => { - console.log('assetRouter onUploadComplete', file); - }), + .onUploadComplete(() => {}), }; export const utapi = new UTApi(); diff --git a/hooks/useProtocolImport.tsx b/hooks/useProtocolImport.tsx index 6e558429..9a6e51ca 100644 --- a/hooks/useProtocolImport.tsx +++ b/hooks/useProtocolImport.tsx @@ -115,8 +115,8 @@ export const useProtocolImport = () => { // Calculate overall asset upload progress by summing the progress // of each asset, then dividing by the total number of assets * 100. - const completeCount = assets.length * 100 + 20; - let currentProgress = 20; + const completeCount = assets.length * 100; + let currentProgress = 0; const uploadedFiles = await uploadFiles({ files: assets.map((asset) => asset.file), @@ -154,7 +154,7 @@ export const useProtocolImport = () => { setProgress({ percent: 100, - status: 'Creating database entry for protocol...', + status: 'Finishing up...', }); const result = await insertProtocol({ From 2a6f1b4eb33a84ff65fd8173b32757d816bef74e Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 9 Nov 2023 16:34:10 +0200 Subject: [PATCH 08/15] WIP refactor to support multiple simultaneous uploads using useReducer and a queue --- .../_components/ProtocolUploader.tsx | 130 +++------ components/ProtocolImport/JobCard.tsx | 117 ++++++++ .../ProtocolImport/JobProgressReducer.ts | 54 ++++ components/ProtocolImport/JobReducer.ts | 106 +++++++ components/ui/CloseButton.tsx | 37 +++ hooks/useProtocolImport.tsx | 258 +++++++++++------- package.json | 2 + pnpm-lock.yaml | 17 +- 8 files changed, 515 insertions(+), 206 deletions(-) create mode 100644 components/ProtocolImport/JobCard.tsx create mode 100644 components/ProtocolImport/JobProgressReducer.ts create mode 100644 components/ProtocolImport/JobReducer.ts create mode 100644 components/ui/CloseButton.tsx diff --git a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx index 9c0110b4..3d4a51d0 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx @@ -2,50 +2,26 @@ import { useDropzone } from 'react-dropzone'; import { Button } from '~/components/ui/Button'; -import ActiveProtocolSwitch from '~/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch'; -import ErrorDialog from '~/components/ui/ErrorDialog'; -import { useToast } from '~/components/ui/use-toast'; -import { Progress } from '~/components/ui/progress'; -import { api } from '~/trpc/client'; -import { clientRevalidateTag } from '~/utils/clientRevalidate'; -import { useRouter } from 'next/navigation'; import { useProtocolImport } from '~/hooks/useProtocolImport'; -import { FileUp, Loader2 } from 'lucide-react'; +import { FileUp } from 'lucide-react'; import { AnimatePresence, motion } from 'framer-motion'; -import { useState } from 'react'; +import JobCard from '~/components/ProtocolImport/JobCard'; const variants = { - initial: { opacity: 0, y: 20 }, - enter: { opacity: 1, y: 0 }, - exit: { opacity: 0, y: -20 }, + enter: { + transition: { staggerChildren: 1, delayChildren: 0.2 }, + }, + exit: { + transition: { staggerChildren: 0.05, staggerDirection: -1 }, + }, }; export default function ProtocolUploader() { - const { error, progress, reset, uploadProtocol } = useProtocolImport(); - - const router = useRouter(); - const utils = api.useUtils(); - const { toast } = useToast(); + const { importProtocols, jobs } = useProtocolImport(); const { getRootProps, getInputProps } = useDropzone({ - disabled: !!progress, - multiple: false, onDropAccepted: async (acceptedFiles) => { - const file = acceptedFiles[0] as File; - const { success } = await uploadProtocol(file); - - if (success) { - toast({ - title: 'Protocol imported!', - description: 'Your protocol has been successfully imported.', - variant: 'success', - }); - - reset(); - await clientRevalidateTag('protocol.get.all'); - await utils.protocol.get.all.invalidate(); - router.refresh(); - } + await importProtocols(acceptedFiles); }, accept: { 'application/octect-stream': ['.netcanvas'], @@ -55,70 +31,28 @@ export default function ProtocolUploader() { return ( <> - -
- - {progress && ( - - -
-
- - - {progress.status} - - -
- -
-
- )} - - {!progress && ( - - -

- Click to select .netcanvas file or drag and drop - here -

-
- )} -
-
+ +
+
+ +

+ Click to select .netcanvas files or drag and drop + here. +

+
+ +
+ {jobs.map((job, index) => ( + + ))} +
+
+
+
); } diff --git a/components/ProtocolImport/JobCard.tsx b/components/ProtocolImport/JobCard.tsx new file mode 100644 index 00000000..64713cfe --- /dev/null +++ b/components/ProtocolImport/JobCard.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import ErrorDialog from '../ui/ErrorDialog'; +import { CloseButton } from '../ui/CloseButton'; +import type { ImportJob } from './JobReducer'; +import { cn } from '~/utils/shadcn'; +import { Progress } from '../ui/progress'; +import { CheckCircle, Loader, Loader2 } from 'lucide-react'; +import { importSteps } from './JobProgressReducer'; +import { Button } from '../ui/Button'; + +const variants = (delay: number) => ({ + enter: { + opacity: 1, + scale: 1, + transition: { + delay, + }, + }, + exit: { + opacity: 0, + scale: 0.5, + }, +}); + +const JobCard = ({ job, delay }: { job: ImportJob; delay: number }) => { + const [showErrorDialog, setShowErrorDialog] = useState(false); + const { error, status, id, cancel } = job; + + const activeStepIndex = importSteps.findIndex( + (step) => step === status.activeStep, + ); + + const waiting = activeStepIndex === -1; + + console.log('job', id, status); + + useEffect(() => { + if (error) { + setShowErrorDialog(true); + } + }, [error]); + + return ( + <> + {error && ( + setShowErrorDialog(false)} + title={error?.title} + description={error?.description} + additionalContent={error?.additionalContent} + /> + )} + + +

{id}

+ {waiting ? ( +
+ +
+ ) : ( +
    + {importSteps.map((s) => { + const isActive = s === status.activeStep; + const isComplete = + activeStepIndex > importSteps.findIndex((step) => step === s); + + return ( +
  • + {isActive && ( + + )} + {isComplete && ( + + )} + {s} + {status.progress && + status.activeStep === 'Uploading assets' && ( + + )} +
  • + ); + })} +
+ )} + {error && ( + + )} +
+ + ); +}; + +export default JobCard; diff --git a/components/ProtocolImport/JobProgressReducer.ts b/components/ProtocolImport/JobProgressReducer.ts new file mode 100644 index 00000000..cc0ffc68 --- /dev/null +++ b/components/ProtocolImport/JobProgressReducer.ts @@ -0,0 +1,54 @@ +export type ProgressItem = { + label: string; + complete: boolean; + active: boolean; + progress?: number; +}; + +export const importSteps = [ + 'Extracting protocol', + 'Validating protocol', + 'Uploading assets', + 'Finishing up', +] as const; + +export type ImportStep = (typeof importSteps)[number]; + +export type JobStatusState = { + activeStep: ImportStep | null; + progress?: number; +}; + +export const jobStatusInitialState: JobStatusState = { + activeStep: null, +}; + +export type JobStatusAction = { + type: 'UPDATE_STATUS'; + payload: { + activeStep: ImportStep; + progress?: number; + }; +}; + +export function jobStatusReducer( + state: JobStatusState, + action?: JobStatusAction, +) { + switch (action?.type) { + case 'UPDATE_STATUS': { + const { activeStep } = action.payload; + + if (!action.payload.progress) { + return { activeStep }; + } + + return { + activeStep, + progress: action.payload.progress, + }; + } + default: + return state; + } +} diff --git a/components/ProtocolImport/JobReducer.ts b/components/ProtocolImport/JobReducer.ts new file mode 100644 index 00000000..a7f1d8b9 --- /dev/null +++ b/components/ProtocolImport/JobReducer.ts @@ -0,0 +1,106 @@ +import type { ErrorState } from '~/hooks/useProtocolImport'; +import { + type JobStatusAction, + jobStatusInitialState, + jobStatusReducer, + type JobStatusState, +} from './JobProgressReducer'; + +export type ImportJob = { + id: string; + file: File; + status: JobStatusState; + error?: ErrorState; +}; + +export const jobInitialState: ImportJob[] = []; + +type AddJobAction = { + type: 'ADD_JOB'; + payload: { + file: File; + }; +}; + +type RemoveJobAction = { + type: 'REMOVE_JOB'; + payload: { + id: string; + }; +}; + +type UpdateStatusAction = JobStatusAction & { + payload: { + id: string; + }; +}; + +type UpdateErrorAction = { + type: 'UPDATE_ERROR'; + payload: { + id: string; + error: ErrorState; + }; +}; + +type Action = + | AddJobAction + | RemoveJobAction + | UpdateStatusAction + | UpdateErrorAction; + +export function jobReducer(state: ImportJob[], action: Action) { + switch (action.type) { + case 'ADD_JOB': { + const newJob = { + id: action.payload.file.name, + file: action.payload.file, + status: jobStatusReducer(jobStatusInitialState), + }; + + return [...state, newJob]; + } + case 'REMOVE_JOB': + return state.filter((job) => job.id !== action.payload.id); + case 'UPDATE_STATUS': { + const { id } = action.payload; + const job = state.find((job) => job.id === id); + + if (!job) { + return state; + } + + return state.map((job) => { + if (job.id === id) { + return { + ...job, + status: jobStatusReducer(job.status, action), + }; + } + + return job; + }); + } + case 'UPDATE_ERROR': { + const { id, error } = action.payload; + const job = state.find((job) => job.id === id); + + if (!job) { + return state; + } + + return state.map((job) => { + if (job.id === id) { + return { + ...job, + error, + }; + } + + return job; + }); + } + default: + throw new Error(); + } +} diff --git a/components/ui/CloseButton.tsx b/components/ui/CloseButton.tsx new file mode 100644 index 00000000..c946628b --- /dev/null +++ b/components/ui/CloseButton.tsx @@ -0,0 +1,37 @@ +import { cn } from '~/utils/shadcn'; + +export const CloseButton = ({ + onClick, + className, +}: { + onClick: () => void; + className?: string; +}) => { + return ( + + ); +}; diff --git a/hooks/useProtocolImport.tsx b/hooks/useProtocolImport.tsx index 9a6e51ca..86fe189f 100644 --- a/hooks/useProtocolImport.tsx +++ b/hooks/useProtocolImport.tsx @@ -1,52 +1,59 @@ -import { XCircle } from 'lucide-react'; -import { useState } from 'react'; -import { ErrorDetails } from '~/components/ErrorDetails'; -import Link from '~/components/Link'; -import { AlertDescription } from '~/components/ui/Alert'; +import { useReducer } from 'react'; import { uploadFiles } from '~/lib/uploadthing-helpers'; import { api } from '~/trpc/client'; import { DatabaseError } from '~/utils/databaseError'; import { ensureError } from '~/utils/ensureError'; +import { queue } from 'async'; import { fileAsArrayBuffer, getProtocolJson, getProtocolAssets, } from '~/utils/protocolImport'; +import { + jobInitialState, + jobReducer, +} from '~/components/ProtocolImport/JobReducer'; +import { AlertDescription } from '~/components/ui/Alert'; +import Link from '~/components/Link'; +import { ErrorDetails } from '~/components/ErrorDetails'; +import { XCircle } from 'lucide-react'; -type ErrorState = { +export type ErrorState = { title: string; description: React.ReactNode; additionalContent?: React.ReactNode; }; -type ProgressState = { - percent: number; - status: string; -}; - export const useProtocolImport = () => { - const [error, setError] = useState(null); - const [progress, setProgress] = useState(null); const { mutateAsync: insertProtocol } = api.protocol.insert.useMutation(); + const [jobs, dispatch] = useReducer(jobReducer, jobInitialState); - const uploadProtocol = async (protocolFile: File) => { + const processJob = async (file: File) => { try { - setProgress({ - percent: 0, - status: 'Processing...', + const fileName = file.name; + + dispatch({ + type: 'UPDATE_STATUS', + payload: { + id: file.name, + activeStep: 'Extracting protocol', + }, }); - const fileName = protocolFile.name; + const fileArrayBuffer = await fileAsArrayBuffer(file); - const fileArrayBuffer = await fileAsArrayBuffer(protocolFile); + // TODO: check if this causes multiple fetches by importing again for each job. const JSZip = (await import('jszip')).default; // Dynamic import to reduce bundle size const zip = await JSZip.loadAsync(fileArrayBuffer); const protocolJson = await getProtocolJson(zip); // Validating protocol... - setProgress({ - percent: 5, - status: 'Validating protocol...', + dispatch({ + type: 'UPDATE_STATUS', + payload: { + id: file.name, + activeStep: 'Validating protocol', + }, }); const { validateProtocol } = await import('@codaco/protocol-validation'); @@ -58,57 +65,66 @@ export const useProtocolImport = () => { // eslint-disable-next-line no-console console.log('validationResult', validationResult); - setError({ - title: 'The protocol is invalid!', - description: ( - <> - - The protocol you uploaded is invalid. See the details below for - specific validation errors that were found. - - - If you believe that your protocol should be valid please ask for - help via our{' '} - - community forum - - . - - - ), - additionalContent: ( - -
    - {[ - ...validationResult.schemaErrors, - ...validationResult.logicErrors, - ].map((validationError, i) => ( -
  • - - - {validationError.message}{' '} - - ({validationError.path}) - - -
  • - ))} -
-
- ), + dispatch({ + type: 'UPDATE_ERROR', + payload: { + id: file.name, + error: { + title: 'The protocol is invalid!', + description: ( + <> + + The protocol you uploaded is invalid. See the details below + for specific validation errors that were found. + + + If you believe that your protocol should be valid please ask + for help via our{' '} + + community forum + + . + + + ), + additionalContent: ( + +
    + {[ + ...validationResult.schemaErrors, + ...validationResult.logicErrors, + ].map((validationError, i) => ( +
  • + + + {validationError.message}{' '} + + ({validationError.path}) + + +
  • + ))} +
+
+ ), + }, + }, }); - setProgress(null); - return { success: false }; + return; } // After this point, assume the protocol is valid. - setProgress({ - percent: 20, - status: 'Uploading assets...', + dispatch({ + type: 'UPDATE_STATUS', + payload: { + id: file.name, + activeStep: 'Uploading assets', + progress: 0, + }, }); const assets = await getProtocolAssets(protocolJson, zip); @@ -123,9 +139,13 @@ export const useProtocolImport = () => { endpoint: 'assetRouter', onUploadProgress({ progress }) { currentProgress += progress; - setProgress({ - percent: Math.round((currentProgress / completeCount) * 100), - status: 'Uploading assets...', + dispatch({ + type: 'UPDATE_STATUS', + payload: { + id: file.name, + activeStep: 'Uploading assets', + progress: Math.round((currentProgress / completeCount) * 100), + }, }); }, }); @@ -152,9 +172,12 @@ export const useProtocolImport = () => { }; }); - setProgress({ - percent: 100, - status: 'Finishing up...', + dispatch({ + type: 'UPDATE_STATUS', + payload: { + id: file.name, + activeStep: 'Finishing up', + }, }); const result = await insertProtocol({ @@ -167,7 +190,13 @@ export const useProtocolImport = () => { throw new DatabaseError(result.error, result.errorDetails); } - return { success: true }; + dispatch({ + type: 'REMOVE_JOB', + payload: { + id: file.name, + }, + }); + return; } catch (e) { // eslint-disable-next-line no-console console.log(e); @@ -175,46 +204,67 @@ export const useProtocolImport = () => { const error = ensureError(e); // Database errors are thrown inside our tRPC router if (error instanceof DatabaseError) { - setError({ - title: 'Database error during protocol import', - description: {error.message}, - additionalContent: ( - -
{error.originalError.toString()}
-
- ), + dispatch({ + type: 'UPDATE_ERROR', + payload: { + id: file.name, + error: { + title: 'Database error during protocol import', + description: {error.message}, + additionalContent: ( + +
{error.originalError.toString()}
+
+ ), + }, + }, }); } else { - setError({ - title: 'Error importing protocol', - description: ( - - There was an unknown error while importing your protocol. The - information below might help us to debug the issue. - - ), - additionalContent: ( - -
{error.message}
-
- ), + dispatch({ + type: 'UPDATE_ERROR', + payload: { + id: file.name, + error: { + title: 'Error importing protocol', + description: ( + + There was an unknown error while importing your protocol. The + information below might help us to debug the issue. + + ), + additionalContent: ( + +
{error.message}
+
+ ), + }, + }, }); } - setProgress(null); - return { success: false }; + return; } }; - const reset = () => { - setError(null); - setProgress(null); + const jobQueue = queue(processJob, 1); + + const importProtocols = async (files: File[]) => { + files.forEach(async (file) => { + dispatch({ + type: 'ADD_JOB', + payload: { + file, + }, + }); + await jobQueue.push(file); + }); }; + const reset = () => {}; + return { - error, - progress, + jobs, reset, - uploadProtocol, + importProtocols, }; }; diff --git a/package.json b/package.json index 47eb4d66..66e9fa00 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,9 @@ "@trpc/next": "^10.41.0", "@trpc/react-query": "^10.41.0", "@trpc/server": "^10.41.0", + "@types/async": "^3.2.23", "@uploadthing/react": "^5.7.0", + "async": "^3.2.5", "bcrypt": "^5.1.1", "blobs": "2.3.0-beta.2", "class-variance-authority": "^0.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0540da5..b69f92b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,9 +101,15 @@ dependencies: '@trpc/server': specifier: ^10.41.0 version: 10.41.0 + '@types/async': + specifier: ^3.2.23 + version: 3.2.23 '@uploadthing/react': specifier: ^5.7.0 version: 5.7.0(next@14.0.0)(react@18.2.0)(uploadthing@5.7.2)(zod@3.22.4) + async: + specifier: ^3.2.5 + version: 3.2.5 bcrypt: specifier: ^5.1.1 version: 5.1.1 @@ -5049,6 +5055,10 @@ packages: resolution: {integrity: sha512-0Z6Tr7wjKJIk4OUEjVUQMtyunLDy339vcMaj38Kpj6jM2OE1p3S4kXExKZ7a3uXQAPCoy3sbrP1wibDKaf39oA==} dev: true + /@types/async@3.2.23: + resolution: {integrity: sha512-/sDXs+HxCtt4/duO0AZFHs+ydQYHzd3qUybEFb+juRWQJNCFj3sYbqM2ABjWi8m7J93KXdJvIbDiARzqqIEESw==} + dev: false + /@types/babel__core@7.20.3: resolution: {integrity: sha512-54fjTSeSHwfan8AyHWrKbfBWiEUrNTZsUwPTDSNaaP1QDQIZbeNUg3a59E9D+375MzUw/x1vx2/0F5LBz+AeYA==} dependencies: @@ -6106,9 +6116,8 @@ packages: resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} dev: true - /async@3.2.4: - resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==} - dev: true + /async@3.2.5: + resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} /asynciterator.prototype@1.0.0: resolution: {integrity: sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg==} @@ -9379,7 +9388,7 @@ packages: engines: {node: '>=10'} hasBin: true dependencies: - async: 3.2.4 + async: 3.2.5 chalk: 4.1.2 filelist: 1.0.4 minimatch: 3.1.2 From 22d609d2bec6be1abc1bcb02f3d68a4b5e70fe66 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 9 Nov 2023 17:01:08 +0200 Subject: [PATCH 09/15] improve styling --- .../_components/ProtocolUploader.tsx | 16 +++-- components/ProtocolImport/JobCard.tsx | 61 +++++++++++++------ hooks/useProtocolImport.tsx | 2 +- 3 files changed, 56 insertions(+), 23 deletions(-) diff --git a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx index 3d4a51d0..67025a04 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx @@ -31,8 +31,11 @@ export default function ProtocolUploader() { return ( <> - -
+ +
-
+ {jobs.map((job, index) => ( ))} -
+
-
+
); diff --git a/components/ProtocolImport/JobCard.tsx b/components/ProtocolImport/JobCard.tsx index 64713cfe..dfc33315 100644 --- a/components/ProtocolImport/JobCard.tsx +++ b/components/ProtocolImport/JobCard.tsx @@ -5,7 +5,7 @@ import { CloseButton } from '../ui/CloseButton'; import type { ImportJob } from './JobReducer'; import { cn } from '~/utils/shadcn'; import { Progress } from '../ui/progress'; -import { CheckCircle, Loader, Loader2 } from 'lucide-react'; +import { CheckCircle, Loader, Loader2, XCircle } from 'lucide-react'; import { importSteps } from './JobProgressReducer'; import { Button } from '../ui/Button'; @@ -32,6 +32,7 @@ const JobCard = ({ job, delay }: { job: ImportJob; delay: number }) => { ); const waiting = activeStepIndex === -1; + const finished = activeStepIndex === importSteps.length - 1; console.log('job', id, status); @@ -41,6 +42,30 @@ const JobCard = ({ job, delay }: { job: ImportJob; delay: number }) => { } }, [error]); + const getListItem = ({ + isActive, + isFailed, + isComplete, + }: { + isActive: boolean; + isFailed: boolean; + isComplete: boolean; + }) => { + if (isFailed) { + return ; + } + + if (isComplete) { + return ; + } + + if (isActive) { + return ; + } + + return ; + }; + return ( <> {error && ( @@ -54,22 +79,27 @@ const JobCard = ({ job, delay }: { job: ImportJob; delay: number }) => { )} -

{id}

+

{id}

{waiting ? (
) : ( -
    +
      {importSteps.map((s) => { const isActive = s === status.activeStep; + const isFailed = s === status.activeStep && error; const isComplete = activeStepIndex > importSteps.findIndex((step) => step === s); @@ -77,21 +107,18 @@ const JobCard = ({ job, delay }: { job: ImportJob; delay: number }) => {
    • - {isActive && ( - - )} - {isComplete && ( - - )} - {s} - {status.progress && - status.activeStep === 'Uploading assets' && ( - + {getListItem({ isActive, isFailed, isComplete })} + + {s} + {status.progress && s === 'Uploading assets' && ( + )} +
    • ); })} @@ -99,7 +126,7 @@ const JobCard = ({ job, delay }: { job: ImportJob; delay: number }) => { )} {error && (
- - - {jobs.map((job, index) => ( - - ))} - - + + {jobs && jobs.length > 0 && ( + + + {jobs.map((job, index) => ( + + cancelJob(job.id)} /> + + ))} + + + )} diff --git a/components/BackgroundBlobs/BackgroundBlobs.tsx b/components/BackgroundBlobs/BackgroundBlobs.tsx index 78852bbf..d69d7c3d 100644 --- a/components/BackgroundBlobs/BackgroundBlobs.tsx +++ b/components/BackgroundBlobs/BackgroundBlobs.tsx @@ -19,13 +19,7 @@ const gradients = [ ['rgb(45, 41, 285)', 'rgb(58,58,217)'], ]; -const SPEED_FACTOR = 1; - -const speeds = { - 1: SPEED_FACTOR * random(3, 6), - 2: SPEED_FACTOR * random(0.5, 1.5), - 3: SPEED_FACTOR * 0.5, -}; +const DEFAULT_SPEED_FACTOR = 1; class NCBlob { layer: 1 | 2 | 3; @@ -48,7 +42,13 @@ class NCBlob { shape2: string | null; interpolator: ((t: number) => string) | null; - constructor(layer: 1 | 2 | 3) { + constructor(layer: 1 | 2 | 3, speedFactor: number) { + const speeds = { + 1: speedFactor * random(3, 6), + 2: speedFactor * random(0.5, 1.5), + 3: speedFactor * 0.5, + }; + this.layer = layer; // Used to determine size and speed this.speed = speeds[layer]; @@ -232,24 +232,31 @@ type BackgroundBlobsProps = { large?: number; medium?: number; small?: number; + speedFactor?: number; + compositeOperation?: GlobalCompositeOperation; + filter?: CanvasFilters['filter']; }; const BackgroundBlobs = ({ large = 2, medium = 4, small = 4, + speedFactor = DEFAULT_SPEED_FACTOR, + compositeOperation = 'screen', + filter = '', }: BackgroundBlobsProps) => { const blobs = useMemo( () => [ - new Array(large).fill(null).map(() => new NCBlob(3)), - new Array(medium).fill(null).map(() => new NCBlob(2)), - new Array(small).fill(null).map(() => new NCBlob(1)), + new Array(large).fill(null).map(() => new NCBlob(3, speedFactor)), + new Array(medium).fill(null).map(() => new NCBlob(2, speedFactor)), + new Array(small).fill(null).map(() => new NCBlob(1, speedFactor)), ], - [large, medium, small], + [large, medium, small, speedFactor], ); const drawBlobs = (ctx: CanvasRenderingContext2D, time: number) => { - ctx.globalCompositeOperation = 'screen'; + ctx.globalCompositeOperation = compositeOperation; + ctx.filter = filter; blobs.forEach((layer) => layer.forEach((blob) => blob.render(ctx, time))); }; diff --git a/components/ProtocolImport/JobCard.tsx b/components/ProtocolImport/JobCard.tsx index dfc33315..d755e8e1 100644 --- a/components/ProtocolImport/JobCard.tsx +++ b/components/ProtocolImport/JobCard.tsx @@ -1,70 +1,59 @@ -import { useEffect, useState } from 'react'; -import { motion } from 'framer-motion'; +import { useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; import ErrorDialog from '../ui/ErrorDialog'; import { CloseButton } from '../ui/CloseButton'; -import type { ImportJob } from './JobReducer'; +import { type ImportJob } from './JobReducer'; import { cn } from '~/utils/shadcn'; import { Progress } from '../ui/progress'; -import { CheckCircle, Loader, Loader2, XCircle } from 'lucide-react'; -import { importSteps } from './JobProgressReducer'; +import { CheckCircle, Loader2, XCircle } from 'lucide-react'; import { Button } from '../ui/Button'; +import BackgroundBlobs from '../BackgroundBlobs/BackgroundBlobs'; +import { stat } from 'fs'; -const variants = (delay: number) => ({ - enter: { +const statusVariants = { + initial: { + opacity: 0, + y: 10, + }, + animate: { opacity: 1, - scale: 1, - transition: { - delay, - }, + y: 0, }, exit: { opacity: 0, - scale: 0.5, + y: -10, }, -}); - -const JobCard = ({ job, delay }: { job: ImportJob; delay: number }) => { - const [showErrorDialog, setShowErrorDialog] = useState(false); - const { error, status, id, cancel } = job; - - const activeStepIndex = importSteps.findIndex( - (step) => step === status.activeStep, - ); - - const waiting = activeStepIndex === -1; - const finished = activeStepIndex === importSteps.length - 1; - - console.log('job', id, status); - - useEffect(() => { - if (error) { - setShowErrorDialog(true); - } - }, [error]); - - const getListItem = ({ - isActive, - isFailed, - isComplete, - }: { - isActive: boolean; - isFailed: boolean; - isComplete: boolean; - }) => { - if (isFailed) { - return ; - } +}; - if (isComplete) { - return ; - } +const iconVariants = { + initial: { + opacity: 0, + x: -10, + }, + animate: { + opacity: 1, + x: 0, + }, + exit: { + opacity: 0, + x: 10, + }, +}; - if (isActive) { - return ; - } +const JobCard = ({ + job, + onCancel, +}: { + job: ImportJob; + onCancel: () => void; +}) => { + const [showErrorDialog, setShowErrorDialog] = useState(false); + const { error, status, id } = job; + const { activeStep } = status; - return ; - }; + const isWaiting = activeStep === 'Waiting to begin'; + const isComplete = activeStep === 'Complete'; + const isActive = !error && !isComplete && !isWaiting; return ( <> @@ -78,64 +67,119 @@ const JobCard = ({ job, delay }: { job: ImportJob; delay: number }) => { /> )} - -

{id}

- {waiting ? ( -
- -
- ) : ( -
    - {importSteps.map((s) => { - const isActive = s === status.activeStep; - const isFailed = s === status.activeStep && error; - const isComplete = - activeStepIndex > importSteps.findIndex((step) => step === s); + {/* {isActive && ( + + + + )} */} + + + {!(isComplete || error) && ( + + )} + {isComplete && } + {error && } - return ( -
  • - {getListItem({ isActive, isFailed, isComplete })} - - {s} - {status.progress && s === 'Uploading assets' && ( - + + + {id} + + + {activeStep && ( + +

    + {activeStep} +

    + {status.progress && ( + + + )} -
    -
  • - ); - })} -
- )} - {error && ( - - )} +
+ )} + {error && ( + + + + )} + + + + + ); diff --git a/components/ProtocolImport/JobProgressReducer.ts b/components/ProtocolImport/JobProgressReducer.ts deleted file mode 100644 index cc0ffc68..00000000 --- a/components/ProtocolImport/JobProgressReducer.ts +++ /dev/null @@ -1,54 +0,0 @@ -export type ProgressItem = { - label: string; - complete: boolean; - active: boolean; - progress?: number; -}; - -export const importSteps = [ - 'Extracting protocol', - 'Validating protocol', - 'Uploading assets', - 'Finishing up', -] as const; - -export type ImportStep = (typeof importSteps)[number]; - -export type JobStatusState = { - activeStep: ImportStep | null; - progress?: number; -}; - -export const jobStatusInitialState: JobStatusState = { - activeStep: null, -}; - -export type JobStatusAction = { - type: 'UPDATE_STATUS'; - payload: { - activeStep: ImportStep; - progress?: number; - }; -}; - -export function jobStatusReducer( - state: JobStatusState, - action?: JobStatusAction, -) { - switch (action?.type) { - case 'UPDATE_STATUS': { - const { activeStep } = action.payload; - - if (!action.payload.progress) { - return { activeStep }; - } - - return { - activeStep, - progress: action.payload.progress, - }; - } - default: - return state; - } -} diff --git a/components/ProtocolImport/JobReducer.ts b/components/ProtocolImport/JobReducer.ts index a7f1d8b9..77c8016a 100644 --- a/components/ProtocolImport/JobReducer.ts +++ b/components/ProtocolImport/JobReducer.ts @@ -1,19 +1,39 @@ -import type { ErrorState } from '~/hooks/useProtocolImport'; -import { - type JobStatusAction, - jobStatusInitialState, - jobStatusReducer, - type JobStatusState, -} from './JobProgressReducer'; +export const importStatuses = [ + 'Waiting to begin', + 'Extracting protocol', + 'Validating protocol', + 'Uploading assets', + 'Finishing up', + 'Complete', +] as const; + +export type ActiveImportStep = (typeof importStatuses)[number]; + +export type BaseImportStatus = { + activeStep: ActiveImportStep; +}; + +export type UploadingImportStatus = BaseImportStatus & { + activeStep: 'Uploading assets'; + progress: number; +}; + +export type ImportStatus = BaseImportStatus | UploadingImportStatus; + +export type ErrorState = { + title: string; + description: React.ReactNode; + additionalContent?: React.ReactNode; +}; export type ImportJob = { id: string; file: File; - status: JobStatusState; + status: ImportStatus; error?: ErrorState; }; -export const jobInitialState: ImportJob[] = []; +export const jobInitialState = []; type AddJobAction = { type: 'ADD_JOB'; @@ -29,9 +49,12 @@ type RemoveJobAction = { }; }; -type UpdateStatusAction = JobStatusAction & { +type UpdateJobStatusAction = { + type: 'UPDATE_STATUS'; payload: { id: string; + activeStep: ActiveImportStep; + progress?: number; }; }; @@ -46,16 +69,18 @@ type UpdateErrorAction = { type Action = | AddJobAction | RemoveJobAction - | UpdateStatusAction + | UpdateJobStatusAction | UpdateErrorAction; export function jobReducer(state: ImportJob[], action: Action) { switch (action.type) { case 'ADD_JOB': { - const newJob = { + const newJob: ImportJob = { id: action.payload.file.name, file: action.payload.file, - status: jobStatusReducer(jobStatusInitialState), + status: { + activeStep: 'Waiting to begin', + }, }; return [...state, newJob]; @@ -63,7 +88,7 @@ export function jobReducer(state: ImportJob[], action: Action) { case 'REMOVE_JOB': return state.filter((job) => job.id !== action.payload.id); case 'UPDATE_STATUS': { - const { id } = action.payload; + const { id, activeStep } = action.payload; const job = state.find((job) => job.id === id); if (!job) { @@ -72,9 +97,20 @@ export function jobReducer(state: ImportJob[], action: Action) { return state.map((job) => { if (job.id === id) { + // Asset upload is the only step that has a progress bar + if (activeStep === 'Uploading assets') { + return { + ...job, + status: { + activeStep, + progress: action.payload.progress, + }, + }; + } + return { ...job, - status: jobStatusReducer(job.status, action), + status: { activeStep }, }; } diff --git a/components/ui/CloseButton.tsx b/components/ui/CloseButton.tsx index c946628b..50e7bfb7 100644 --- a/components/ui/CloseButton.tsx +++ b/components/ui/CloseButton.tsx @@ -12,7 +12,8 @@ export const CloseButton = ({ type="button" onClick={onClick} className={cn( - 'rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground', + 'text-red', + 'rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted', className, )} > @@ -26,7 +27,7 @@ export const CloseButton = ({ strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" - className="h-4 w-4" + className="h-4 w-4 fill-current" > diff --git a/components/ui/progress.tsx b/components/ui/progress.tsx index fbe291e8..9202fc09 100644 --- a/components/ui/progress.tsx +++ b/components/ui/progress.tsx @@ -1,28 +1,31 @@ -"use client" +'use client'; -import * as React from "react" -import * as ProgressPrimitive from "@radix-ui/react-progress" +import * as React from 'react'; +import * as ProgressPrimitive from '@radix-ui/react-progress'; -import { cn } from "~/utils/shadcn" +import { cn } from '~/utils/shadcn'; const Progress = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, value, ...props }, ref) => ( +>(({ className, indicatorClasses, value, ...props }, ref) => ( -)) -Progress.displayName = ProgressPrimitive.Root.displayName +)); +Progress.displayName = ProgressPrimitive.Root.displayName; -export { Progress } +export { Progress }; diff --git a/hooks/useProtocolImport.tsx b/hooks/useProtocolImport.tsx index 493e2c05..08efcc31 100644 --- a/hooks/useProtocolImport.tsx +++ b/hooks/useProtocolImport.tsx @@ -17,18 +17,62 @@ import { AlertDescription } from '~/components/ui/Alert'; import Link from '~/components/Link'; import { ErrorDetails } from '~/components/ErrorDetails'; import { XCircle } from 'lucide-react'; +import { clientRevalidateTag } from '~/utils/clientRevalidate'; -export type ErrorState = { - title: string; - description: React.ReactNode; - additionalContent?: React.ReactNode; -}; +// Utility helper for adding artificial delay to async functions +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export const useProtocolImport = () => { - const { mutateAsync: insertProtocol } = api.protocol.insert.useMutation(); const [jobs, dispatch] = useReducer(jobReducer, jobInitialState); + const { mutateAsync: insertProtocol } = api.protocol.insert.useMutation({ + async onSuccess() { + await clientRevalidateTag('protocol.get.all'); + }, + }); + + // const testProcessJob = async (file: File) => { + // await new Promise((resolve) => setTimeout(resolve, 3000)); + + // dispatch({ + // type: 'UPDATE_STATUS', + // payload: { + // id: file.name, + // activeStep: 'Extracting protocol', + // }, + // }); + + // await new Promise((resolve) => setTimeout(resolve, 5000)); + + // dispatch({ + // type: 'UPDATE_STATUS', + // payload: { + // id: file.name, + // activeStep: 'Complete', + // }, + // }); + + // await new Promise((resolve) => setTimeout(resolve, 1000)); + + // dispatch({ + // type: 'REMOVE_JOB', + // payload: { + // id: file.name, + // }, + // }); + + // return; + // }; + + /** + * This is the main job processing function. Takes a file, and handles all + * the steps required to import it into the database, updating the job + * status as it goes. + */ const processJob = async (file: File) => { + // Small artificial delay to allow for the job card to animate in + await sleep(1500); + try { const fileName = file.name; @@ -46,6 +90,7 @@ export const useProtocolImport = () => { const JSZip = (await import('jszip')).default; // Dynamic import to reduce bundle size const zip = await JSZip.loadAsync(fileArrayBuffer); const protocolJson = await getProtocolJson(zip); + await sleep(1500); // Validating protocol... dispatch({ @@ -58,8 +103,8 @@ export const useProtocolImport = () => { const { validateProtocol } = await import('@codaco/protocol-validation'); - // This function will throw on validation errors, with type ValidationError const validationResult = await validateProtocol(protocolJson); + await sleep(1500); if (!validationResult.isValid) { // eslint-disable-next-line no-console @@ -150,10 +195,14 @@ export const useProtocolImport = () => { }, }); - // The asset 'name' prop matches across the assets array and the - // uploadedFiles array, so we can just map over one of them and - // merge the properties we need to add to the database. - const assetsWithUploadMeta = assets.map((asset) => { + /** + * We now need to merge the metadata from the uploaded files with the + * asset metadata from the protocol json, so that we can insert the + * assets into the database. + * + * The 'name' prop matches across both, we can use that to merge them. + */ + const assetsWithCombinedMetadata = assets.map((asset) => { const uploadedAsset = uploadedFiles.find( (uploadedFile) => uploadedFile.name === asset.name, ); @@ -162,6 +211,7 @@ export const useProtocolImport = () => { throw new Error('Asset upload failed'); } + // Ensure this matches the input schema in the protocol router return { key: uploadedAsset.key, assetId: asset.assetId, @@ -183,26 +233,20 @@ export const useProtocolImport = () => { const result = await insertProtocol({ protocol: protocolJson, protocolName: fileName, - assets: assetsWithUploadMeta, + assets: assetsWithCombinedMetadata, }); if (result.error) { throw new DatabaseError(result.error, result.errorDetails); } - dispatch({ - type: 'REMOVE_JOB', - payload: { - id: file.name, - }, - }); + // Complete! 🚀 return; } catch (e) { // eslint-disable-next-line no-console console.log(e); - const error = ensureError(e); - // Database errors are thrown inside our tRPC router + if (error instanceof DatabaseError) { dispatch({ type: 'UPDATE_ERROR', @@ -246,25 +290,51 @@ export const useProtocolImport = () => { } }; - const jobQueue = queue(processJob, 3); + /** + * Create an async processing que for import jobs, to allow for multiple + * protocols to be imported with a nice UX. + * + * Concurrency set to 1 for now. We can increase this because unzipping and + * validation are basically instant, but the asset upload and db insertion + * need a separate queue to avoid consuming too much bandwidth or overloading + * the database. + */ + const jobQueue = queue(processJob, 1); - const importProtocols = async (files: File[]) => { - files.forEach(async (file) => { + const importProtocols = (files: File[]) => { + files.forEach((file) => { dispatch({ type: 'ADD_JOB', payload: { file, }, }); - await jobQueue.push(file); + + jobQueue.push(file).catch((error) => { + // eslint-disable-next-line no-console + console.log('jobQueue error', error); + }); }); }; - const reset = () => {}; + const cancelAllJobs = () => { + jobQueue.kill(); + }; + + const cancelJob = (id: string) => { + jobQueue.remove(({ data }) => data.name === id); + dispatch({ + type: 'REMOVE_JOB', + payload: { + id, + }, + }); + }; return { jobs, - reset, importProtocols, + cancelJob, + cancelAllJobs, }; }; diff --git a/utils/protocolImport.tsx b/utils/protocolImport.tsx index a099272f..3bf7aaa7 100644 --- a/utils/protocolImport.tsx +++ b/utils/protocolImport.tsx @@ -1,6 +1,7 @@ import type { AssetManifest, Protocol } from '@codaco/shared-consts'; import type Zip from 'jszip'; +// Fetch protocol.json as a parsed object from the protocol zip. export const getProtocolJson = async (protocolZip: Zip) => { const protocolString = await protocolZip ?.file('protocol.json') @@ -15,13 +16,11 @@ export const getProtocolJson = async (protocolZip: Zip) => { return protocolJson; }; -type ProtocolAsset = { - assetId: string; - name: string; - type: string; - file: File; -}; - +/** + * Fetch all assets listed in the protocol json from the protocol zip, and + * return them as a collection of ProtocolAsset objects, which includes useful + * metadata about the asset. + */ export const getProtocolAssets = async ( protocolJson: Protocol, protocolZip: Zip, @@ -33,16 +32,21 @@ export const getProtocolAssets = async ( } /** - * Structure of an Asset: - * - Asset is an object. Key is the UID. - * - ID property is the same as the key. + * Structure of an asset in network canvas protocols: + * - An asset in the manifest is an object whose key is a UID. + * - The ID property is the same as the key (duplicated for convinience :/) * - Name property is the original file name when added to Architect * - Source property is the internal path to the file in the zip, which is a * separate UID + file extension. - * - The type property is one of the NC asset types (e.g. 'image', 'video', etc.) + * - The type property is one of the NC asset types (e.g. 'image', 'video', + * etc.) */ - - const files: ProtocolAsset[] = []; + const files: { + assetId: string; + name: string; + type: string; + file: File; + }[] = []; await Promise.all( Object.keys(assetManifest).map(async (key) => { @@ -58,12 +62,11 @@ export const getProtocolAssets = async ( ); } - // data.append('files', file, asset.source); files.push({ assetId: key, name: asset.source, type: asset.type, - file: new File([file], asset.source), + file: new File([file], asset.source), // Convert Blob to File with filename }); }), ); @@ -71,6 +74,8 @@ export const getProtocolAssets = async ( return files; }; +// Helper method for reading a file as an ArrayBuffer. Useful for preparing a +// File to be read by JSZip. export function fileAsArrayBuffer(file: Blob | File): Promise { return new Promise((resolve) => { const reader = new FileReader(); From 317b360cb52b1288bab4868c5c8b704a7e30322f Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Mon, 13 Nov 2023 17:06:56 +0200 Subject: [PATCH 11/15] adjust state structure and finalize UI --- .../_components/ProtocolUploader.tsx | 28 +- .../ProtocolsTable/ProtocolsTable.tsx | 16 +- .../BackgroundBlobs/BackgroundBlobs.tsx | 56 ++-- components/ProtocolImport/JobCard.tsx | 151 +++++----- components/ProtocolImport/JobReducer.ts | 46 ++- components/ui/Button.tsx | 1 + components/ui/progress.tsx | 8 +- hooks/useProtocolImport.tsx | 271 ++++++++++-------- server/routers/protocol.ts | 45 +-- 9 files changed, 352 insertions(+), 270 deletions(-) diff --git a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx index 95b3b441..854c3ba2 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx @@ -6,9 +6,11 @@ import { useProtocolImport } from '~/hooks/useProtocolImport'; import { FileUp } from 'lucide-react'; import { AnimatePresence, motion } from 'framer-motion'; import JobCard from '~/components/ProtocolImport/JobCard'; +import { useCallback } from 'react'; export default function ProtocolUploader() { - const { importProtocols, jobs, cancelJob } = useProtocolImport(); + const { importProtocols, jobs, cancelJob, cancelAllJobs } = + useProtocolImport(); const { getRootProps, getInputProps, open } = useDropzone({ // Disable automatic opening of file dialog - we do it manually to allow for @@ -21,13 +23,18 @@ export default function ProtocolUploader() { }, }); + const handleCancelJob = useCallback( + (jobId: string) => () => cancelJob(jobId), + [cancelJob], + ); + return ( <> - +
{jobs.map((job, index) => ( - cancelJob(job.id)} /> + ))} + {jobs.length > 1 && ( + + + + )} )} - +
); diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx index d86be3fd..3ddfecc5 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx @@ -13,13 +13,16 @@ export const ProtocolsTable = ({ }: { initialData: ProtocolWithInterviews[]; }) => { - const { data: protocols } = api.protocol.get.all.useQuery(undefined, { - initialData, - refetchOnMount: false, - onError(error) { - throw new Error(error.message); + const { data: protocols, isLoading } = api.protocol.get.all.useQuery( + undefined, + { + initialData, + refetchOnMount: false, + onError(error) { + throw new Error(error.message); + }, }, - }); + ); const [showAlertDialog, setShowAlertDialog] = useState(false); const [protocolsToDelete, setProtocolsToDelete] = @@ -32,6 +35,7 @@ export const ProtocolsTable = ({ return ( <> + {isLoading &&
Loading...
} { - const blobs = useMemo( - () => [ - new Array(large).fill(null).map(() => new NCBlob(3, speedFactor)), - new Array(medium).fill(null).map(() => new NCBlob(2, speedFactor)), - new Array(small).fill(null).map(() => new NCBlob(1, speedFactor)), - ], - [large, medium, small, speedFactor], - ); - - const drawBlobs = (ctx: CanvasRenderingContext2D, time: number) => { - ctx.globalCompositeOperation = compositeOperation; - ctx.filter = filter; - blobs.forEach((layer) => layer.forEach((blob) => blob.render(ctx, time))); - }; - - return ; -}; +const BackgroundBlobs = memo( + ({ + large = 2, + medium = 4, + small = 4, + speedFactor = DEFAULT_SPEED_FACTOR, + compositeOperation = 'screen', + filter = '', + }: BackgroundBlobsProps) => { + const blobs = useMemo( + () => [ + new Array(large).fill(null).map(() => new NCBlob(3, speedFactor)), + new Array(medium).fill(null).map(() => new NCBlob(2, speedFactor)), + new Array(small).fill(null).map(() => new NCBlob(1, speedFactor)), + ], + [large, medium, small, speedFactor], + ); + + const drawBlobs = (ctx: CanvasRenderingContext2D, time: number) => { + ctx.globalCompositeOperation = compositeOperation; + ctx.filter = filter; + blobs.forEach((layer) => layer.forEach((blob) => blob.render(ctx, time))); + }; + + return ; + }, +); + +BackgroundBlobs.displayName = 'BackgroundBlobs'; export default BackgroundBlobs; diff --git a/components/ProtocolImport/JobCard.tsx b/components/ProtocolImport/JobCard.tsx index d755e8e1..0920609d 100644 --- a/components/ProtocolImport/JobCard.tsx +++ b/components/ProtocolImport/JobCard.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import ErrorDialog from '../ui/ErrorDialog'; import { CloseButton } from '../ui/CloseButton'; @@ -8,7 +8,6 @@ import { Progress } from '../ui/progress'; import { CheckCircle, Loader2, XCircle } from 'lucide-react'; import { Button } from '../ui/Button'; import BackgroundBlobs from '../BackgroundBlobs/BackgroundBlobs'; -import { stat } from 'fs'; const statusVariants = { initial: { @@ -25,21 +24,6 @@ const statusVariants = { }, }; -const iconVariants = { - initial: { - opacity: 0, - x: -10, - }, - animate: { - opacity: 1, - x: 0, - }, - exit: { - opacity: 0, - x: 10, - }, -}; - const JobCard = ({ job, onCancel, @@ -48,13 +32,26 @@ const JobCard = ({ onCancel: () => void; }) => { const [showErrorDialog, setShowErrorDialog] = useState(false); - const { error, status, id } = job; - const { activeStep } = status; + const { error, status, progress, id } = job; - const isWaiting = activeStep === 'Waiting to begin'; - const isComplete = activeStep === 'Complete'; + const isWaiting = !status; + const isComplete = status === 'Complete'; const isActive = !error && !isComplete && !isWaiting; + // Self-dismiss when complete after 2 seconds + useEffect(() => { + if (isComplete) { + const timeout = setTimeout(() => { + onCancel(); + }, 2000); + + return () => { + clearTimeout(timeout); + }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isComplete]); + return ( <> {error && ( @@ -68,101 +65,115 @@ const JobCard = ({ )} - {/* {isActive && ( + {isActive && ( - )} */} + )} - {!(isComplete || error) && ( - - )} - {isComplete && } - {error && } + + {!(isComplete || error) && ( + + )} + {isComplete && } + {error && } + - + {id} - {activeStep && ( + {!error && status && ( -

- {activeStep} -

- {status.progress && ( - - - - )} + + {status} + +
+ )} + {progress && ( + + )} {error && ( - - - - Import Protocol - - - - - ); -}; - -export default ImportProtocolModal; diff --git a/app/(interview)/interview/new/page.tsx b/app/(interview)/interview/new/page.tsx index 3b341b7f..6d79d43b 100644 --- a/app/(interview)/interview/new/page.tsx +++ b/app/(interview)/interview/new/page.tsx @@ -18,7 +18,7 @@ export default async function Page({ }; }) { // check if active protocol exists - const activeProtocol = await api.protocol.getCurrentlyActive.query(); + const activeProtocol = await api.protocol.active.get.query(); if (!activeProtocol) { return ( { - setProtocolUploaded(true); - }; - const handleNextStep = () => { setCurrentStep(currentStep + 1).catch(() => {}); }; @@ -32,9 +28,7 @@ function ConfigureStudy() {
{protocolUploaded && }
- {!protocolUploaded && ( - - )} +
+ ); +}; + +export default ActiveButton; diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx index d408b22c..0c1c6540 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx @@ -1,16 +1,14 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ 'use client'; import { type ColumnDef, flexRender } from '@tanstack/react-table'; - import { Checkbox } from '~/components/ui/checkbox'; - +import ActiveButton from './ActiveButton'; import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader'; - -import ActiveProtocolSwitch from '~/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch'; - import type { ProtocolWithInterviews } from '~/shared/types'; +import { dateOptions } from '~/components/DataTable/helpers'; -export const ProtocolColumns = (): ColumnDef[] => [ +export const ProtocolColumns: ColumnDef[] = [ { id: 'select', header: ({ table }) => ( @@ -30,35 +28,31 @@ export const ProtocolColumns = (): ColumnDef[] => [ enableSorting: false, enableHiding: false, }, + { + id: 'active', + enableSorting: true, + accessorFn: (row) => row.active, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + }, { accessorKey: 'name', header: ({ column }) => { return ; }, cell: ({ row }) => { - return ( -
- {flexRender(row.original.name, row)} -
- ); + return flexRender(row.original.name, row); }, }, { accessorKey: 'description', header: 'Description', cell: ({ row }) => { - return ( -
- {flexRender(row.original.description, row)} -
- ); + return flexRender(row.original.description, row); }, }, { @@ -66,9 +60,16 @@ export const ProtocolColumns = (): ColumnDef[] => [ header: ({ column }) => { return ; }, - cell: ({ row }) => ( -
- {new Date(row.original.importedAt).toLocaleString()} + cell: ({ + row, + table: { + options: { meta }, + }, + }) => ( +
+ {new Intl.DateTimeFormat(meta.navigatorLanguages, dateOptions).format( + new Date(row.original.importedAt), + )}
), }, @@ -77,31 +78,17 @@ export const ProtocolColumns = (): ColumnDef[] => [ header: ({ column }) => { return ; }, - cell: ({ row }) => ( -
- {new Date(row.original.lastModified).toLocaleString()} -
- ), - }, - { - accessorKey: 'schemaVersion', - header: 'Schema Version', - cell: ({ row }) => ( -
- {row.original.schemaVersion} + cell: ({ + row, + table: { + options: { meta }, + }, + }) => ( +
+ {new Intl.DateTimeFormat(meta.navigatorLanguages, dateOptions).format( + new Date(row.original.lastModified), + )}
), }, - { - accessorKey: 'active', - header: 'Active', - cell: ({ row }) => { - return ( - - ); - }, - }, ]; diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx index 3ddfecc5..e21b8752 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx @@ -37,7 +37,7 @@ export const ProtocolsTable = ({ <> {isLoading &&
Loading...
} = TTable & { + options?: { + meta?: { + getRowClasses?: (row: Row) => string | undefined; + navigatorLanguages?: string[]; + }; + }; +}; + interface DataTableProps { columns?: ColumnDef[]; data: TData[]; @@ -44,6 +55,15 @@ export function DataTable({ const [sorting, setSorting] = useState([]); const [isDeleting, setIsDeleting] = useState(false); const [rowSelection, setRowSelection] = useState({}); + const [navigatorLanguages, setNavigatorLanguages] = useState< + string[] | undefined + >(); + + useEffect(() => { + if (window.navigator.languages) { + setNavigatorLanguages(window.navigator.languages as string[]); + } + }, []); const [columnFilters, setColumnFilters] = useState([]); @@ -92,12 +112,17 @@ export function DataTable({ onRowSelectionChange: setRowSelection, onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), + meta: { + getRowClasses: (row) => + row.original.active && 'bg-purple-500/30 hover:bg-purple-500/40', + navigatorLanguages, + }, state: { sorting, rowSelection, columnFilters, }, - }); + }) as CustomTable; const hasSelectedRows = table.getSelectedRowModel().rows.length > 0; @@ -147,6 +172,7 @@ export function DataTable({ {row.getVisibleCells().map((cell) => ( diff --git a/components/DataTable/DefaultColumns.tsx b/components/DataTable/DefaultColumns.tsx index 8e71296a..2d807276 100644 --- a/components/DataTable/DefaultColumns.tsx +++ b/components/DataTable/DefaultColumns.tsx @@ -1,8 +1,4 @@ -import { type ColumnDef } from '@tanstack/react-table'; - -export const makeDefaultColumns = ( - data: TData[], -): ColumnDef[] => { +export const makeDefaultColumns = (data: TData[]) => { const firstRow = data[0]; if (!firstRow || typeof firstRow !== 'object') { @@ -11,7 +7,7 @@ export const makeDefaultColumns = ( const columnKeys = Object.keys(firstRow); - const columns: ColumnDef[] = columnKeys.map((key) => { + const columns = columnKeys.map((key) => { return { accessorKey: key, header: key, diff --git a/components/DataTable/helpers.ts b/components/DataTable/helpers.ts new file mode 100644 index 00000000..7a65e8cc --- /dev/null +++ b/components/DataTable/helpers.ts @@ -0,0 +1,8 @@ +// Display options for dates: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat#using_options +export const dateOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', +}; diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 00000000..ccc9cd78 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "~/utils/shadcn" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/declarations.d.ts b/declarations.d.ts index 8b137891..e69de29b 100644 --- a/declarations.d.ts +++ b/declarations.d.ts @@ -1 +0,0 @@ - diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca19a179..54f3b7af 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,6 +14,7 @@ datasource db { model Protocol { id String @id @default(cuid()) + active Boolean @default(false) hash String @unique name String schemaVersion Int @@ -24,7 +25,6 @@ model Protocol { codebook Json assets Asset[] interviews Interview[] - active Boolean @default(false) } model Asset { diff --git a/server/routers/protocol.ts b/server/routers/protocol.ts index f7cba90c..ecf77c28 100644 --- a/server/routers/protocol.ts +++ b/server/routers/protocol.ts @@ -7,11 +7,6 @@ import { Prisma } from '@prisma/client'; import { utapi } from '~/app/api/uploadthing/core'; import type { Protocol } from '@codaco/shared-consts'; -const updateActiveProtocolSchema = z.object({ - input: z.boolean(), - hash: z.string(), -}); - export const assetInsertSchema = z.array( z.object({ key: z.string(), @@ -114,64 +109,41 @@ export const protocolRouter = router({ active: true, }, }); - return protocol; - }), - is: protectedProcedure.input(z.string()).query(async ({ input: hash }) => { - const protocol = await prisma.protocol.findFirst({ - where: { - hash, - }, - select: { - active: true, - }, - }); - return protocol?.active || false; + return protocol?.id ?? null; }), + is: protectedProcedure + .input(z.string()) + .mutation(async ({ input: protocolId }) => { + const protocol = await prisma.protocol.findFirst({ + where: { + id: protocolId, + }, + }); + + return protocol?.active ?? false; + }), set: protectedProcedure - .input(updateActiveProtocolSchema) - .mutation(async ({ input: { input, hash } }) => { + .input(z.string()) + .mutation(async ({ input: protocolId }) => { try { - const currentActive = await prisma.protocol.findFirst({ - where: { - active: true, - }, - }); - - // If input is false, deactivate the active protocol - if (!input) { - await prisma.protocol.update({ + await prisma.$transaction([ + prisma.protocol.updateMany({ where: { - hash: hash, active: true, }, data: { active: false, }, - }); - return { error: null, success: true }; - } - - // Deactivate the current active protocol, if it exists - if (currentActive) { - await prisma.protocol.update({ + }), + prisma.protocol.update({ where: { - id: currentActive.id, + id: protocolId, }, data: { - active: false, + active: true, }, - }); - } - - // Make the protocol with the given hash active - await prisma.protocol.update({ - where: { - hash, - }, - data: { - active: true, - }, - }); + }), + ]); return { error: null, success: true }; } catch (error) { From ba5e8619df0a5f5a887cf0f3f52392c360984d7f Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 21 Nov 2023 22:37:21 +0200 Subject: [PATCH 15/15] fix TS errors --- .../_components/ActiveProtocolSwitch.tsx | 61 ------------------- .../ProtocolsTable/ActiveButton.tsx | 2 + .../_components/ProtocolsTable/Columns.tsx | 19 ++++-- .../ProtocolsTable/ProtocolsTable.tsx | 7 ++- components/DataTable/DataTable.tsx | 5 +- 5 files changed, 24 insertions(+), 70 deletions(-) delete mode 100644 app/(dashboard)/dashboard/_components/ActiveProtocolSwitch.tsx diff --git a/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch.tsx b/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch.tsx deleted file mode 100644 index 432b7168..00000000 --- a/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch.tsx +++ /dev/null @@ -1,61 +0,0 @@ -'use client'; - -import { useRouter } from 'next/navigation'; -import { api } from '~/trpc/client'; -import { Switch } from '~/components/ui/switch'; - -const ActiveProtocolSwitch = ({ - initialData, - hash, -}: { - initialData: boolean; - hash: string; -}) => { - const utils = api.useUtils(); - const router = useRouter(); - - const { data: isActive } = api.protocol.active.is.useQuery(hash, { - initialData, - refetchOnMount: false, - onError: (err) => { - throw new Error(err.message); - }, - }); - - const { mutateAsync: setActive } = api.protocol.active.set.useMutation({ - async onMutate(variables) { - const { input: newState } = variables; - - await utils.protocol.active.get.cancel(); - const previous = utils.protocol.active.get.getData(); - - if (previous) { - utils.protocol.active.get.setData(undefined, { - ...previous, - active: newState, - }); - - return previous; - } - }, - onError: (err, _newState, previousState) => { - utils.protocol.active.get.setData(undefined, previousState); - throw new Error(err.message); - }, - onSuccess: () => { - router.refresh(); - }, - }); - - const handleCheckedChange = async () => { - await setActive({ input: !isActive, hash }); - }; - - return ( - void handleCheckedChange()} - /> - ); -}; -export default ActiveProtocolSwitch; diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/ActiveButton.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/ActiveButton.tsx index 8bacf247..07be3fc8 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/ActiveButton.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/ActiveButton.tsx @@ -1,6 +1,7 @@ 'use client'; import { api } from '~/trpc/client'; import { BadgeCheck } from 'lucide-react'; +import { clientRevalidateTag } from '~/utils/clientRevalidate'; const ActiveButton = ({ active, @@ -45,6 +46,7 @@ const ActiveButton = ({ onSettled: () => { void utils.protocol.get.all.invalidate(); void utils.protocol.active.get.invalidate(); + void clientRevalidateTag('protocol.get.all'); }, onError: (_error, _newActiveProtocolId, context) => { utils.protocol.get.all.setData(undefined, context?.protocolGetAll); diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx index 0c1c6540..f3076389 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ 'use client'; @@ -67,9 +68,12 @@ export const ProtocolColumns: ColumnDef[] = [ }, }) => (
- {new Intl.DateTimeFormat(meta.navigatorLanguages, dateOptions).format( - new Date(row.original.importedAt), - )} + { + // @ts-ignore + new Intl.DateTimeFormat(meta?.navigatorLanguages, dateOptions).format( + new Date(row.original.importedAt), + ) + }
), }, @@ -85,9 +89,12 @@ export const ProtocolColumns: ColumnDef[] = [ }, }) => (
- {new Intl.DateTimeFormat(meta.navigatorLanguages, dateOptions).format( - new Date(row.original.lastModified), - )} + { + // @ts-ignore + new Intl.DateTimeFormat(meta?.navigatorLanguages, dateOptions).format( + new Date(row.original.lastModified), + ) + }
), }, diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx index e21b8752..b09a7617 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx @@ -36,12 +36,17 @@ export const ProtocolsTable = ({ return ( <> {isLoading &&
Loading...
} - columns={ProtocolColumns} data={protocols} filterColumnAccessorKey="name" handleDeleteSelected={handleDelete} actions={ActionsDropdown} + calculateRowClasses={(row) => + row.original.active + ? 'bg-purple-500/30 hover:bg-purple-500/40' + : undefined + } /> { handleDeleteSelected?: (data: TData[]) => Promise | void; actions?: React.ComponentType<{ row: Row; data: TData[] }>; actionsHeader?: React.ReactNode; + calculateRowClasses?: (row: Row) => string | undefined; } export function DataTable({ @@ -51,6 +52,7 @@ export function DataTable({ filterColumnAccessorKey = '', actions, actionsHeader, + calculateRowClasses = () => undefined, }: DataTableProps) { const [sorting, setSorting] = useState([]); const [isDeleting, setIsDeleting] = useState(false); @@ -113,8 +115,7 @@ export function DataTable({ onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), meta: { - getRowClasses: (row) => - row.original.active && 'bg-purple-500/30 hover:bg-purple-500/40', + getRowClasses: (row: Row) => calculateRowClasses(row), navigatorLanguages, }, state: {