Files
windmill/frontend/src/lib/components/table/AutoDataTable.svelte
2024-04-24 23:22:37 +02:00

416 lines
12 KiB
Svelte

<script lang="ts">
import { ArrowDown, ArrowUp, Download, MoreVertical, MoveVertical, Columns } from 'lucide-svelte'
import Dropdown from '../DropdownV2.svelte'
import Cell from './Cell.svelte'
import DataTable from './DataTable.svelte'
import Head from './Head.svelte'
import Row from './Row.svelte'
import { pluralize, sendUserToast } from '$lib/utils'
import Badge from '$lib/components/common/badge/Badge.svelte'
import { isEmail, isLink } from './tableUtils'
import type { BadgeColor } from '../common'
import Popover from '../Popover.svelte'
import DarkModeObserver from '../DarkModeObserver.svelte'
import Button from '../common/button/Button.svelte'
import { Parser } from '@json2csv/plainjs'
export let objects: Array<Record<string, any>> = []
let currentPage = 1
let perPage = 5
let search: string = ''
$: structuredObjects = computeStructuredObjects(objects)
let headers: string[] = []
function computeStructuredObjects(objects: Array<Record<string, any>>) {
if (Array.isArray(objects)) {
let nextId = 1
let hds: string[] = []
let objs = objects.map((obj) => {
let rowData = obj && typeof obj == 'object' ? obj : {}
let ks = Object.keys(rowData)
ks.forEach((x) => {
if (!hds.includes(x)) {
hds.push(x)
}
})
return {
_id: nextId++,
rowData
}
})
headers = hds
return objs
} else {
headers = []
return []
}
}
function adjustCurrentPage() {
const totalItems = objects.length
const totalPages = Math.ceil(totalItems / perPage)
if (currentPage > totalPages) {
currentPage = totalPages > 0 ? totalPages : 1
}
}
$: perPage && adjustCurrentPage()
$: data = structuredObjects
.filter(
({ rowData }) =>
search == undefined ||
search == '' ||
Object.values(rowData).some((value) =>
JSON.stringify(value).toLowerCase().includes(search.toLowerCase())
)
)
.sort((a, b) => {
if (!activeSorting) return 0
const valA = a.rowData[activeSorting.column]
const valB = b.rowData[activeSorting.column]
const isAsc = activeSorting.direction === 'asc'
if (valA == undefined || valA == null) {
return isAsc ? -1 : 1
}
if (valB == undefined || valB == null) {
return isAsc ? 1 : -1
}
if (isAsc) {
return valA > valB ? 1 : -1
} else {
return valA > valB ? -1 : 1
}
})
.slice((currentPage - 1) * perPage, currentPage * perPage)
let activeSorting:
| {
column: string
direction: 'asc' | 'desc'
}
| undefined = undefined
let selection = [] as Array<number>
// Function to handle individual row checkbox change
function handleCheckboxChange(rowId: number) {
if (selection.includes(rowId)) {
// Remove the id from the selection array
selection = selection.filter((id) => id !== rowId)
} else {
// Add the id to the selection array
selection = [...selection, rowId]
}
}
// Function to handle select all checkbox change
function handleSelectAllChange() {
if (selection.length === 0 || selection.length < data.length) {
// Select all rows
selection = data.map((row) => row._id)
} else {
// Deselect all rows
selection = []
}
selection = [...selection]
}
let renderCount = 0
const badgeColors: BadgeColor[] = ['gray', 'blue', 'red', 'green', 'yellow', 'indigo']
const darkBadgeColors: BadgeColor[] = [
'dark-gray',
'dark-blue',
'dark-red',
'dark-green',
'dark-yellow',
'dark-indigo'
]
let darkMode = false
let wrapperWidth = 0
// function isSortable(key: string) {
// let value = objects?.[0]?.[key]
// let typof = typeof value
// return (value != undefined && typof === 'string') || typof === 'number' || typof === 'boolean'
// }
function convertJsonToCsv(arr: Array<Record<string, any>>): string {
try {
const parser = new Parser({})
const csv = parser.parse(arr)
return csv
} catch (err) {
throw new Error('An error occured when generating CSV:' + err)
}
}
</script>
<DarkModeObserver bind:darkMode />
<div class="w-full" bind:clientWidth={wrapperWidth}>
<div class="flex flex-col gap-2 py-2 my-2" style={`max-width: ${wrapperWidth}px;`}>
<div class="flex flex-row justify-between items-center gap-2">
<div class="flex flex-row gap-2 items-center whitespace-nowrap w-full">
<input bind:value={search} placeholder="Search..." class="h-8 !text-xs" />
{#if selection.length > 0}
<span class="text-xs text-gray-500 dark:text-gray-200">
{pluralize(selection?.length ?? 1, 'item') + ' selected'}
</span>
{/if}
</div>
<div class="flex flex-row items-center gap-2">
<Button
size="xs"
color="light"
startIcon={{ icon: Download }}
on:click={() => {
try {
const csvContent = convertJsonToCsv(
structuredObjects
.filter(({ _id }) => {
if (selection.length > 0) {
return selection.includes(_id)
} else {
return true
}
})
.map((obj) => obj.rowData)
)
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.setAttribute('href', url)
link.setAttribute('download', 'data.csv')
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch (err) {
sendUserToast(err, true)
}
}}
>
{#if selection.length > 0}
Download selected as CSV
{:else}
Download as CSV
{/if}
</Button>
<Dropdown
items={() => {
const actions = [
{
displayName: 'Download JSON',
icon: Download,
action: () => {
const json = JSON.stringify(objects, null, 2)
const blob = new Blob([json], { type: 'text/json;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.setAttribute('href', url)
link.setAttribute('download', 'data.json')
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
}
]
if (selection.length > 0) {
actions.push({
displayName: 'Clear selection',
icon: Columns,
action: () => {
selection = []
renderCount++
}
})
}
return actions
}}
>
<svelte:fragment slot="buttonReplacement">
<MoreVertical
size={8}
class="w-8 h-8 p-2 hover:bg-surface-hover cursor-pointer rounded-md"
/>
</svelte:fragment>
</Dropdown>
</div>
</div>
{#key renderCount}
{#if data.length == 0}
<div class="flex flex-col items-center justify-center border rounded-md py-8">
<div class="text-gray-500 dark:text-gray-200 text-sm"> No data found </div>
<div class="text-gray-500 dark:text-gray-200 text-xs">
Try changing your search query
</div>
</div>
{:else}
<DataTable
size="sm"
shouldHidePagination={false}
paginated={true}
bind:currentPage
bind:perPage
on:next={() => {
currentPage += 1
}}
on:previous={() => {
currentPage -= 1
}}
on:change={(event) => {
currentPage = event.detail
}}
showNext={currentPage * perPage < objects.length}
>
<Head>
<tr>
<Cell head first={true} last={false}>
<input type="checkbox" class="!w-4 !h-4" on:change={handleSelectAllChange} />
</Cell>
{#each headers ?? [] as key, index}
<Cell head last={index == headers.length - 1}>
<div class="flex flex-row gap-1 items-center">
{key}
{#if activeSorting?.column === key}
<button
class="p-1 w-6 h-6 flex justify-center items-center"
on:click={() => {
activeSorting = {
column: key,
direction: activeSorting?.direction == 'asc' ? 'desc' : 'asc'
}
}}
>
{#if activeSorting?.direction == 'asc'}
<ArrowDown size="16" />
{:else}
<ArrowUp size="16" />
{/if}
</button>
{:else}
<button
class="p-1 w-6 h-6 flex justify-center items-center"
on:click={() => {
activeSorting = {
column: key,
direction: activeSorting?.direction == 'asc' ? 'desc' : 'asc'
}
}}
>
<MoveVertical size="16" class=" hover:text-gray-600 text-gray-400" />
</button>
{/if}
</div>
</Cell>
{/each}
</tr>
</Head>
<tbody class="divide-y">
{#each data as { _id, rowData }, index (index)}
<Row dividable selected={selection.includes(_id)}>
<Cell first={true} last={false} class="w-6">
<input
type="checkbox"
class="!w-4 !h-4"
checked={selection.includes(_id)}
on:change={() => handleCheckboxChange(_id)}
/>
</Cell>
{#each headers as key, index}
{@const value = rowData[key]}
<Cell last={index == Object.values(rowData ?? {}).length - 1}>
{#if Array.isArray(value) && value.length === 0}
<div />
{:else if Array.isArray(value) && typeof value?.[0] === 'string'}
<div class="flex flex-row gap-1 w-full max-w-32 flex-wrap min-w-32">
{#each value as item, index}
<Badge
color={darkMode
? darkBadgeColors[index % darkBadgeColors.length]
: badgeColors[index % badgeColors.length]}
>
{item}
</Badge>
{/each}
</div>
{:else if Array.isArray(value) && typeof value?.[0] === 'number'}
<div class="flex flex-row gap-1 w-full max-w-32 flex-wrap min-w-32">
{#each value as val}
<div
class="p-2 bg-surface-secondary rounded-md text-2xs text-wrap whitespace-pre-wrap flex flex-grow w-max overflow-hidden"
>
{JSON.stringify(val, null, 2)}
</div>
{/each}
</div>
{:else if Array.isArray(value)}
<div class="flex flex-row gap-1 w-full flex-wrap min-w-96">
{#each value as val}
<div
class="p-2 bg-surface-secondary rounded-md text-2xs text-wrap whitespace-pre-wrap flex flex-grow w-max overflow-hidden"
>
{JSON.stringify(val, null, 2)}
</div>
{/each}
</div>
{:else if typeof value === 'string' && isEmail(value)}
<a href={`mailto:${value}`} class="hover:underline">
{value}
</a>
{:else if typeof value === 'string' && isLink(value)}
<a href={value} target="_blank" class="hover:underline">
{value}
</a>
{:else}
{@const txt =
value == undefined
? ''
: typeof value != 'string'
? JSON.stringify(value)
: value}
<Popover
placement="bottom"
notClickable
disablePopup={typeof value === 'string' && value.length < 100}
>
<div
class="max-w-80 text-wrap whitespace-pre-wrap flex flex-grow w-max three-lines cursor-text"
>
{txt.length > 100 ? txt.slice(0, 100) + '...' : txt}
</div>
<svelte:fragment slot="text">{txt}</svelte:fragment>
</Popover>
{/if}
</Cell>
{/each}
</Row>
{/each}
</tbody>
</DataTable>
{/if}
{/key}
</div>
</div>
<style>
.three-lines {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>