Skip to content

Commit

Permalink
feat: csv importing (#47)
Browse files Browse the repository at this point in the history
* feat: proof of concept csv importing

* feat: update import code

* fix: remove unused variable

* docs: document the existence of csv importing
  • Loading branch information
Arcath authored Oct 20, 2024
1 parent da387d5 commit 5ef0442
Show file tree
Hide file tree
Showing 5 changed files with 264 additions and 0 deletions.
234 changes: 234 additions & 0 deletions app/routes/app.$assetslug.import.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof loader> = ({data}) => {
return [
{
title: pageTitle(data!.asset.singular, 'Import')
}
]
}

const AssetImport = () => {
const [stage, setStage] = useState(1)
const [csvFile, setCsvFile] = useState<undefined | File>(undefined)
const [csvDetails, setCsvDetails] = useState<
Papa.ParseResult<string[]>['data']
>([])
const [columnMappings, setColumnMappings] = useState<string[]>([])
const [importedCount, dispatchImportedCount] = useReducer(
(state: {count: number}) => {
const newState = {...state}

newState.count += 1

return newState
},
{count: 0}
)
const {asset} = useLoaderData<typeof loader>()

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])

Check warning on line 127 in app/routes/app.$assetslug.import.tsx

View workflow job for this annotation

GitHub Actions / ⬣ ESLint

React Hook useEffect has missing dependencies: 'asset.slug' and 'columnMappings'. Either include them or remove the dependency array

Check warning on line 127 in app/routes/app.$assetslug.import.tsx

View workflow job for this annotation

GitHub Actions / ⬣ ESLint

React Hook useEffect has missing dependencies: 'asset.slug' and 'columnMappings'. Either include them or remove the dependency array

switch (stage) {
default:
case 1:
return (
<div className="grid grid-cols-4 gap-4">
<div className="entry">
<h2>CSV Importer</h2>
<Input
type="file"
onChange={e => {
setCsvFile(e.target.files![0])
}}
/>
<Button
className="bg-success"
disabled={typeof csvFile === 'undefined'}
onClick={() => {
Papa.parse<string[]>(csvFile!, {
complete: results => {
setCsvDetails(results.data)
setStage(2)
}
})
}}
>
Import
</Button>
</div>
</div>
)
break
case 2:
return (
<div className="grid grid-cols-4 gap-4">
<div className="entry">
<h2>CSV Importer</h2>
<p>Import file: {csvFile?.name}</p>
</div>
<div className="entry col-span-2">
<h2>Column Matcher</h2>
<table>
<thead>
<tr>
{csvDetails[0].map((v, i) => {
return <td key={i}>{v}</td>
})}
</tr>
</thead>
<tbody>
<tr>
{csvDetails[0].map((v, i) => {
return (
<td key={i}>
<Select
onChange={e => {
columnMappings[i] = e.target.value
setColumnMappings([...columnMappings])
}}
>
<option value={-1}>Do not import</option>
{asset.assetFields.map(({field}) => {
return (
<option key={field.id} value={field.id}>
{field.name}
</option>
)
})}
</Select>
</td>
)
})}
</tr>
</tbody>
</table>
<Button
className="bg-success"
onClick={() => {
setStage(3)
}}
>
Import
</Button>
</div>
</div>
)
break
case 3:
return (
<div className="grid grid-cols-4 gap-4">
<div className="entry">
<h2>CSV Importer</h2>
<p>Import file: {csvFile?.name}</p>
</div>
<div className="entry col-span-2">Importing...</div>
<div className="entry">
<h2>Imported</h2>
{importedCount.count} /{' '}
{csvDetails.filter(v => v.length !== 1 && v[0] !== '').length - 1}
</div>
</div>
)
break
}
}

export default AssetImport
5 changes: 5 additions & 0 deletions app/routes/app.$assetslug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
5 changes: 5 additions & 0 deletions docs/docs/concepts/assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down

0 comments on commit 5ef0442

Please sign in to comment.