diff --git a/app/routes/app.$assetslug.import.tsx b/app/routes/app.$assetslug.import.tsx new file mode 100644 index 0000000..c4ebb40 --- /dev/null +++ b/app/routes/app.$assetslug.import.tsx @@ -0,0 +1,234 @@ +import { + type LoaderFunctionArgs, + type ActionFunctionArgs, + type MetaFunction, + json +} from '@remix-run/node' +import {useLoaderData} from '@remix-run/react' +import {asyncForEach, asyncMap} from '@arcath/utils' +import {useState, useEffect, useReducer} from 'react' +import Papa from 'papaparse' + +import {ensureUser} from '~/lib/utils/ensure-user' +import {getPrisma} from '~/lib/prisma.server' +import {Button} from '~/lib/components/button' +import {pageTitle} from '~/lib/utils/page-title' +import {Input, Select} from '~/lib/components/input' + +export const loader = async ({request, params}: LoaderFunctionArgs) => { + const user = await ensureUser(request, 'asset:view', { + assetSlug: params.assetslug + }) + + const prisma = getPrisma() + + const asset = await prisma.asset.findFirstOrThrow({ + where: {slug: params.assetslug}, + include: {assetFields: {include: {field: true}, orderBy: {order: 'asc'}}} + }) + + return json({user, asset}) +} + +export const action = async ({request, params}: ActionFunctionArgs) => { + const user = await ensureUser(request, 'asset:write', { + assetSlug: params.assetslug + }) + + const prisma = getPrisma() + + const asset = await prisma.asset.findFirstOrThrow({ + where: {slug: params.assetslug}, + include: {assetFields: {include: {field: true}, orderBy: {order: 'asc'}}} + }) + + const reader = request.body?.getReader() + + const data = await reader?.read() + + const {record, columnMappings} = JSON.parse(data!.value!.toString()) as { + record: string[] + columnMappings: string[] + } + + const entry = await prisma.entry.create({ + data: {assetId: asset.id, aclId: asset.aclId} + }) + + await asyncMap( + record, + async (value, i): Promise<{error: string; field: string} | boolean> => { + if (!columnMappings[i]) { + return false + } + + await prisma.value.create({ + data: { + entryId: entry.id, + fieldId: columnMappings[i], + value, + lastEditedById: user.id + } + }) + + return true + } + ) + + return json({status: 200}) +} + +export const meta: MetaFunction = ({data}) => { + return [ + { + title: pageTitle(data!.asset.singular, 'Import') + } + ] +} + +const AssetImport = () => { + const [stage, setStage] = useState(1) + const [csvFile, setCsvFile] = useState(undefined) + const [csvDetails, setCsvDetails] = useState< + Papa.ParseResult['data'] + >([]) + const [columnMappings, setColumnMappings] = useState([]) + const [importedCount, dispatchImportedCount] = useReducer( + (state: {count: number}) => { + const newState = {...state} + + newState.count += 1 + + return newState + }, + {count: 0} + ) + const {asset} = useLoaderData() + + useEffect(() => { + if (stage === 3) { + asyncForEach(csvDetails, async (v, i) => { + if (i === 0) { + return + } + + if (v.length === 1 && v[0] === '') { + return + } + + await fetch(`/app/${asset.slug}/import`, { + method: 'POST', + body: JSON.stringify({record: v, columnMappings}) + }) + + dispatchImportedCount() + }) + } + }, [stage, csvDetails]) + + switch (stage) { + default: + case 1: + return ( +
+
+

CSV Importer

+ { + setCsvFile(e.target.files![0]) + }} + /> + +
+
+ ) + break + case 2: + return ( +
+
+

CSV Importer

+

Import file: {csvFile?.name}

+
+
+

Column Matcher

+ + + + {csvDetails[0].map((v, i) => { + return + })} + + + + + {csvDetails[0].map((v, i) => { + return ( + + ) + })} + + +
{v}
+ +
+ +
+
+ ) + break + case 3: + return ( +
+
+

CSV Importer

+

Import file: {csvFile?.name}

+
+
Importing...
+
+

Imported

+ {importedCount.count} /{' '} + {csvDetails.filter(v => v.length !== 1 && v[0] !== '').length - 1} +
+
+ ) + break + } +} + +export default AssetImport diff --git a/app/routes/app.$assetslug.tsx b/app/routes/app.$assetslug.tsx index 59be4c5..5269f5f 100644 --- a/app/routes/app.$assetslug.tsx +++ b/app/routes/app.$assetslug.tsx @@ -46,6 +46,11 @@ const Asset = () => { link: `/app/${asset.slug}/add`, label: `Add ${asset.singular}`, className: 'bg-success' + }, + { + link: `/app/${asset.slug}/import`, + label: `Import ${asset.plural}`, + className: 'bg-info' } ] case 'routes/app.$assetslug.$entry._index': diff --git a/docs/docs/concepts/assets.md b/docs/docs/concepts/assets.md index e8521d2..c66abad 100644 --- a/docs/docs/concepts/assets.md +++ b/docs/docs/concepts/assets.md @@ -26,3 +26,8 @@ Any field can be added to an asset. When adding a field you set the _Helper Text_ which is the text that will appear under the field in the editors. The order is a numerical order for the field in the forms and display. A field can be set to unique within the asset, or globally within the whole of Net-Doc. + +## Importing a CSV + +On the asset list click _Import (Plural)_. Select your CSV and then match the +columns of the CSV to the fields in the asset. diff --git a/package-lock.json b/package-lock.json index bb0b79e..982bc29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "node-cron": "^3.0.3", "npm-run-all": "^4.1.5", "nprogress": "^0.2.0", + "papaparse": "^5.4.1", "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -41,6 +42,7 @@ "@remix-run/dev": "^2.13.1", "@types/bcrypt": "^5.0.2", "@types/nprogress": "^0.2.3", + "@types/papaparse": "^5.3.14", "@types/qrcode": "^1.5.5", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", @@ -3515,6 +3517,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/papaparse": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.14.tgz", + "integrity": "sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", @@ -16625,6 +16637,12 @@ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", "dev": true }, + "node_modules/papaparse": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", + "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", diff --git a/package.json b/package.json index e030651..c116d34 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "node-cron": "^3.0.3", "npm-run-all": "^4.1.5", "nprogress": "^0.2.0", + "papaparse": "^5.4.1", "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -55,6 +56,7 @@ "@remix-run/dev": "^2.13.1", "@types/bcrypt": "^5.0.2", "@types/nprogress": "^0.2.3", + "@types/papaparse": "^5.3.14", "@types/qrcode": "^1.5.5", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1",