Skip to content

Commit

Permalink
feat: sortable tables, see #15
Browse files Browse the repository at this point in the history
  • Loading branch information
Arcath committed Jun 17, 2024
1 parent 04bba35 commit 37c008b
Show file tree
Hide file tree
Showing 4 changed files with 276 additions and 58 deletions.
112 changes: 112 additions & 0 deletions app/lib/components/sortable-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {type Asset, type AssetField, type Field} from '@prisma/client'
import {type IndexedArray} from '@arcath/utils'
import {Link} from '@remix-run/react'

import {FIELDS} from '../fields/field'

import {useSortableData} from '../hooks/use-sortable-data'

export const SortableTable = ({
asset,
entries,
values
}: {
asset: Asset & {assetFields: Array<AssetField & {field: Field}>}
entries: Array<{id: string; name: string}>
values: IndexedArray<{
id: string
value: string
type: string
lookup: string
}>
}) => {
const {data, sort, sortOrder, sortedBy} = useSortableData({
asset,
entries,
values,
sortedBy: asset.nameFieldId,
sortOrder: 'ASC'
})

return (
<table className="entry-table">
<thead>
<tr>
<th>
{asset.singular}
<button
onClick={() => {
sort(
sortedBy === asset.nameFieldId && sortOrder === 'ASC'
? 'DESC'
: 'ASC',
asset.nameFieldId
)
}}
>
{sortedBy === asset.nameFieldId && sortOrder === 'ASC'
? '🔽'
: '🔼'}
</button>
</th>
{asset.assetFields
.filter(({displayOnTable}) => displayOnTable)
.map(({id, field}) => {
return (
<th key={id}>
{field.name}{' '}
<button
onClick={() => {
sort(
sortedBy === field.id && sortOrder === 'ASC'
? 'DESC'
: 'ASC',
field.id
)
}}
>
{sortedBy === field.id && sortOrder === 'ASC' ? '🔽' : '🔼'}
</button>
</th>
)
})}
</tr>
</thead>
<tbody>
{data.map(({id, fields}) => {
return (
<tr key={id}>
{asset.assetFields
.filter(
({displayOnTable, field}) =>
displayOnTable || field.id === asset.nameFieldId
)
.map(({fieldId, field}) => {
const value = fields[fieldId].value

const Value = () => {
if (fieldId === asset.nameFieldId) {
return (
<Link to={`/app/${asset.slug}/${id}`}>{value}</Link>
)
}

return FIELDS[field.type].listComponent({
value,
title: field.name,
meta: field.meta
})
}
return (
<td key={fieldId}>
<Value />
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
)
}
124 changes: 124 additions & 0 deletions app/lib/hooks/use-sortable-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import {useReducer} from 'react'
import {type IndexedArray} from '@arcath/utils'

type SortableDataInput = {
asset: {
nameFieldId: string
slug: string
assetFields: Array<{
id: string
displayOnTable: boolean
field: {id: string; name: string}
}>
}
entries: Array<{id: string}>
values: IndexedArray<{
id: string
lookup: string
value: string
type: string
}>
sortedBy: string
sortOrder: 'ASC' | 'DESC'
}

type SortableActions =
| {
type: 'sort'
data: {field: string; direction: 'ASC' | 'DESC'}
}
| {type: 'rebuild'; data: SortableDataInput}

const useSortableDataReducer = (
state: ReturnType<typeof generateSortedData>,
action: SortableActions
) => {
const newState = {...state}

switch (action.type) {
case 'sort':
return generateSortedData({
asset: state.asset,
entries: state.entries,
values: state.values,
sortOrder: action.data.direction,
sortedBy: action.data.field
})
default:
return newState
}
}

const generateSortedData = ({
asset,
entries,
values,
sortOrder,
sortedBy
}: SortableDataInput) => {
const data = entries
.map(({id}) => {
const entryRow: {
fields: {
[fieldId: string]: {value: string; type: string; entryId: string}
}
id: string
} = {id, fields: {}}

asset.assetFields
.filter(
({displayOnTable, field}) =>
displayOnTable || field.id === asset.nameFieldId
)
.forEach(({field}) => {
const lookup = `${id}/${field.id}`

const {type, value} = values[lookup]
? values[lookup]
: {value: '', type: 'text'}

entryRow.fields[field.id] = {type, value, entryId: id}
})

return entryRow
})
.sort((a, b) => {
return a.fields[sortedBy].value.localeCompare(b.fields[sortedBy].value)
})

return {
data: sortOrder === 'DESC' ? data.reverse() : data,
asset,
entries,
values,
sortOrder,
sortedBy
}
}

export const useSortableData = ({
asset,
entries,
values,
sortOrder,
sortedBy
}: SortableDataInput) => {
const [state, dispatch] = useReducer(
useSortableDataReducer,
{asset, entries, values, sortOrder, sortedBy},
generateSortedData
)

const sort = (direction: 'ASC' | 'DESC', field: string) =>
dispatch({type: 'sort', data: {direction, field}})

const rebuild = (data: SortableDataInput) => dispatch({type: 'rebuild', data})

return {
data: state.data,
sortedBy: state.sortedBy,
sortOrder: state.sortOrder,
sort,
rebuild
}
}
90 changes: 33 additions & 57 deletions app/routes/app.$assetslug._index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import {indexedBy} from '@arcath/utils'

import {ensureUser} from '~/lib/utils/ensure-user'
import {getPrisma} from '~/lib/prisma.server'
import {Link, useLoaderData} from '@remix-run/react'
import {useLoaderData} from '@remix-run/react'
import {pageTitle} from '~/lib/utils/page-title'
import {FIELDS} from '~/lib/fields/field'
import {createTimings} from '~/lib/utils/timings.server'

import {SortableTable} from '~/lib/components/sortable-table'

export const loader = async ({request, params}: LoaderFunctionArgs) => {
const {time, headers} = createTimings()

Expand Down Expand Up @@ -48,26 +49,41 @@ export const loader = async ({request, params}: LoaderFunctionArgs) => {
OR
(type = "user" AND target = ${user.id})
)
)
ORDER BY lower(name) ASC`
)`
)

const extraValues = await time(
'getColumns',
'Get Column Values',
() => prisma.$queryRaw<
Array<{
valueId: string
name: string
id: string
value: string
type: string
lookup: string
}>
>`SELECT Value.id as valueId, Field.name, Value.value, Field.type, Value.entryId || '/' || Value.fieldId as lookup FROM AssetField
INNER JOIN Asset on Asset.id = AssetField.assetId
INNER JOIN Field on Field.id = AssetField.fieldId
INNER JOIN Value on Value.fieldId = AssetField.fieldId
WHERE AssetField.displayOnTable = true AND AssetField.assetId = (SELECT id FROM Asset WHERE slug = ${params.assetslug})`
>`SELECT Value.id, Value.value, Value.entryId || '/' || Value.fieldId as lookup, Field.type FROM Value
INNER JOIN Entry ON Entry.id = Value.entryId
INNER JOIN Asset ON Asset.id = Entry.assetId
INNER JOIN AssetField ON AssetField.assetId = Asset.id AND AssetField.fieldId = Value.fieldId
INNER JOIN Field ON Field.id = Value.fieldId
WHERE
((AssetField.displayOnTable = true) OR (Value.fieldId = Asset.nameFieldId))
AND
Value.entryId IN (SELECT Entry.id FROM Entry
WHERE
assetId = (SELECT id from Asset WHERE slug = ${params.assetslug})
AND
deleted = false
AND
aclId IN (SELECT aclId FROM ACLEntry
WHERE read = true AND (
(type = "role" AND target = ${user.role})
OR
(type = "user" AND target = ${user.id})
)
)
)`
)

return json(
Expand All @@ -89,52 +105,12 @@ const Asset = () => {

return (
<div>
<table className="entry-table">
<thead>
<tr>
<th>{asset.singular}</th>
{asset.assetFields
.filter(({displayOnTable}) => displayOnTable)
.map(({id, field}) => {
return <th key={id}>{field.name}</th>
})}
</tr>
</thead>
<tbody>
{entries.map(({id, name}) => {
return (
<tr key={id}>
<td>
<Link to={`/app/${asset.slug}/${id}`}>{name}</Link>
</td>
{asset.assetFields
.filter(({displayOnTable}) => displayOnTable)
.map(({field}) => {
const lookup = `${id}/${field.id}`

const {value} = values[lookup]
? values[lookup]
: {value: ''}

const Value = () => {
return FIELDS[field.type].listComponent({
value,
title: field.name,
meta: field.meta
})
}

return (
<td key={lookup}>
<Value />
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
<SortableTable
asset={asset}
entries={entries}
values={values}
key={asset.slug}
/>
</div>
)
}
Expand Down
8 changes: 7 additions & 1 deletion app/routes/app.$assetslug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ const Asset = () => {
const matches = useMatches()

const actions = () => {
const {id, params} = matches.pop()!
const match = matches.pop()

if (!match) {
return []
}

const {id, params} = match

invariant(id)
invariant(params)
Expand Down

0 comments on commit 37c008b

Please sign in to comment.