feat(frontend): Rich table display (#3028)

* feat(frontend): rich debug table

* feat(frontend): rich debug table

* feat(frontend): rich debug table

* feat(frontend): wip

* feat(frontend): table v0

* feat(frontend): fix layout audit page

* feat(frontend): display rich result by default

* feat(frontend): add selected rows

* feat(frontend): add unique ids

* feat(frontend): restore max-h

* feat(frontend): md support + remove sorting on types that don't support comparaison

* feat(frontend): fix actions

* feat(frontend): fix md

* feat(frontend): fix md

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Faton Ramadani
2024-01-18 17:26:46 +01:00
committed by GitHub
parent c4320556fc
commit 78f80c8b0d
9 changed files with 523 additions and 63 deletions
+4 -4
View File
@@ -102,7 +102,7 @@
"svelte-range-slider-pips": "^2.2.3",
"svelte-splitpanes": "^0.8.0",
"svelte2tsx": "^0.6.16",
"tailwindcss": "^3.3.2",
"tailwindcss": "^3.4.1",
"tslib": "^2.6.1",
"typescript": "^5.1.3",
"vite": "^4.5.0",
@@ -8835,9 +8835,9 @@
}
},
"node_modules/tailwindcss": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.5.tgz",
"integrity": "sha512-5SEZU4J7pxZgSkv7FP1zY8i2TIAOooNZ1e/OGtxIEv6GltpoiXUqWvLy89+a10qYTB1N5Ifkuw9lqQkN9sscvA==",
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz",
"integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==",
"dev": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
+1 -1
View File
@@ -62,7 +62,7 @@
"svelte-range-slider-pips": "^2.2.3",
"svelte-splitpanes": "^0.8.0",
"svelte2tsx": "^0.6.16",
"tailwindcss": "^3.3.2",
"tailwindcss": "^3.4.1",
"tslib": "^2.6.1",
"typescript": "^5.1.3",
"vite": "^4.5.0",
@@ -4,10 +4,12 @@
import TableCustom from './TableCustom.svelte'
import { copyToClipboard, roughSizeOfObject, truncate } from '$lib/utils'
import { Button, Drawer, DrawerContent } from './common'
import { ClipboardCopy, Download, Expand, PanelRightOpen } from 'lucide-svelte'
import { ClipboardCopy, Download, Expand, PanelRightOpen, Table2 } from 'lucide-svelte'
import Portal from 'svelte-portal'
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
import S3FilePicker from './S3FilePicker.svelte'
import AutoDataTable from './table/AutoDataTable.svelte'
import Markdown from 'svelte-exmarkdown'
export let result: any
export let requireHtmlApproval = false
@@ -32,6 +34,7 @@
| 's3object'
| 's3object-list'
| 'plain'
| 'markdown'
| undefined
$: resultKind = inferResultKind(result)
@@ -93,9 +96,9 @@
return 'json'
}
if (isRectangularArray(result)) {
if ((keys.length == 1 && keys[0] == 'table-row') || isRectangularArray(result)) {
return 'table-row'
} else if (isObjectOfArray(result, keys)) {
} else if ((keys.length == 1 && keys[0] == 'table-col') || isObjectOfArray(result, keys)) {
return 'table-col'
} else if (keys.length == 1 && keys[0] == 'html') {
return 'html'
@@ -138,6 +141,8 @@
result.every((elt) => inferResultKind(elt) === 's3object')
) {
return 's3object-list'
} else if (keys.length === 1 && (keys.includes('md') || keys.includes('markdown'))) {
return 'markdown'
}
} catch (err) {}
}
@@ -158,6 +163,42 @@
return obj.content
}
}
function isArrayWithObjects(json) {
return (
Array.isArray(json) &&
json.length > 0 &&
json.every((item) => typeof item === 'object' && Object.keys(item).length > 0)
)
}
$: isTableDisplay = isArrayWithObjects(result)
let richRender: boolean = true
type InputObject = { [key: string]: number[] }
function transform(input: InputObject): any[] {
const maxLength = Math.max(...Object.values(input).map((arr) => arr.length))
const result: Array<{
[key: string]: number | null
}> = []
for (let i = 0; i < maxLength; i++) {
const obj: { [key: string]: number | null } = {}
for (const key of Object.keys(input)) {
if (i < input[key].length) {
obj[key] = input[key][i]
} else {
obj[key] = null
}
}
result.push(obj)
}
return result
}
</script>
<div class="inline-highlight relative grow min-h-[200px]">
@@ -178,36 +219,30 @@
><ClipboardCopy size={16} /></button
>
<button on:click={jsonViewer.openDrawer}><Expand size={16} /></button>
{#if isTableDisplay}
<button
aria-label="Render as table"
on:click={() => {
richRender = !richRender
}}
>
<Table2 size={16} class={richRender ? 'text-blue-500' : ''} /></button
>
{/if}
</div>
{/if}</div
>
{/if}
{#if !forceJson && resultKind == 'table-col'}<div
class="grid grid-flow-col-dense border rounded-md"
>
{#each Object.keys(result) as col}
<div class="flex flex-col max-h-40 min-w-full">
<div
class="px-12 text-left uppercase border-b bg-surface-secondary overflow-hidden rounded-t-md"
>
{col}
</div>
{#if Array.isArray(result[col])}
{#each result[col] as item}
<div class="px-12 text-left text-xs whitespace-nowrap overflow-auto pb-2">
{typeof item === 'string' ? item : JSON.stringify(item)}
</div>
{/each}
{/if}
</div>
{/each}
</div>
{:else if !forceJson && resultKind == 'table-row'}<div
class="grid grid-flow-col-dense border border-gray-200"
>
{#if !forceJson && resultKind == 'table-col'}
{@const data = 'table-col' in result ? result['table-col'] : result}
<AutoDataTable objects={transform(data)} />
{:else if !forceJson && resultKind == 'table-row'}
{@const data = 'table-row' in result ? result['table-row'] : result}
<div class="grid grid-flow-col-dense border border-gray-200">
<TableCustom>
<tbody slot="body">
{#each asListOfList(result) as row}
{#each Array.isArray(asListOfList(data)) ? asListOfList(data) : [] as row}
<tr>
{#each row as v}
<td class="!text-xs">{truncate(JSON.stringify(v), 200) ?? ''}</td>
@@ -337,6 +372,12 @@
</button>
{/each}
</div>
{:else if !forceJson && resultKind == 'markdown'}
<div class="prose dark:prose-invert">
<Markdown md={result?.md ?? result?.markdown} />
</div>
{:else if !forceJson && isTableDisplay && richRender}
<AutoDataTable objects={result} />
{:else if largeObject}
{#if typeof result == 'object' && 'filename' in result && 'file' in result}
<div
@@ -564,11 +564,10 @@
)}
style={$appStore.css?.['app']?.['viewer']?.style}
>
<div class="absolute bottom-2 left-4 z-50">
<div class="flex flex-row gap-2 text-xs items-center">
<div class="absolute bottom-2 left-2 z-50 border bg-surface">
<div class="flex flex-row gap-2 text-xs items-center p-0.5">
<Button
color="light"
variant="border"
size="xs2"
disabled={$scale <= 30}
on:click={() => {
@@ -580,7 +579,6 @@
{$scale}%
<Button
color="light"
variant="border"
size="xs2"
disabled={$scale >= 100}
on:click={() => {
@@ -0,0 +1,406 @@
<script lang="ts">
import {
ArrowDown,
ArrowUp,
Download,
EyeIcon,
MoreVertical,
MoveVertical,
Columns,
EyeOff
} 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 } 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'
export let objects: Array<Record<string, any>> = []
let currentPage = 1
let perPage = 5
let search: string = ''
let nextId = 1
const structuredObjects = objects.map((obj) => {
return {
_id: nextId++,
rowData: { ...obj }
}
})
$: data = structuredObjects
.filter(({ rowData }) =>
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]
if (activeSorting.direction === 'asc') {
return valA > valB ? 1 : -1
} else {
return valA < valB ? 1 : -1
}
})
.slice((currentPage - 1) * perPage, currentPage * perPage)
let hiddenColumns = [] as Array<string>
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) {
return (
typeof objects[0][key] === 'string' ||
typeof objects[0][key] === 'number' ||
typeof objects[0][key] === 'boolean'
)
}
</script>
<DarkModeObserver bind:darkMode />
<div class="w-full" bind:clientWidth={wrapperWidth}>
<div class="flex flex-col gap-2 py-4 my-4" style={`max-width: ${wrapperWidth}px;`}>
<div class="flex flex-row justify-between items-center">
<div class="flex flex-row gap-2 items-center whitespace-nowrap w-full">
<input bind:value={search} placeholder="Search..." class="h-8 !text-xs !w-80" />
{#if selection.length > 0}
<span class="text-xs text-gray-500 dark:text-gray-200">
{pluralize(selection?.length ?? 1, 'item') + ' selected'}
</span>
{/if}
{#if hiddenColumns.length > 0}
<div class="flex flex-row gap-2 justify-center items-center mx-2">
<span class="text-xs text-gray-500 dark:text-gray-200" />
<Button
size="xs2"
color="light"
variant="border"
on:click={() => {
hiddenColumns = []
}}
startIcon={{
icon: Columns
}}
>
Display hidden columns ({pluralize(hiddenColumns?.length ?? 1, 'column')})
</Button>
</div>
{/if}
</div>
<div class="flex flex-row items-center gap-2">
<Button
size="xs"
color="light"
startIcon={{ icon: Download }}
on:click={() => {
const csv = structuredObjects
.filter(({ _id }) => {
if (selection.length > 0) {
return selection.includes(_id)
} else {
return true
}
})
.map(({ rowData }) => Object.values(rowData).join(','))
.join('\n')
const blob = new Blob([csv], { 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)
}}
>
{#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 (hiddenColumns.length > 0) {
actions.push({
displayName: 'Display hidden columns',
icon: EyeIcon,
action: () => {
hiddenColumns = []
}
})
}
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 Object.keys(data[0].rowData ?? {}) ?? [] as key, index}
<Cell head last={index == Object.keys(objects[0] ?? {}).length - 1}>
<div class="flex flex-row gap-1 items-center">
{key}
{#if hiddenColumns.includes(key)}
<button
class="p-1 w-6 h-6 flex justify-center items-center"
on:click={() => {
hiddenColumns = hiddenColumns.filter((col) => col !== key)
}}
>
<EyeOff size="16" class="hover:text-gray-600 text-gray-400 rounded-full " />
</button>
{:else}
<button
class="p-1 w-6 h-6 flex justify-center items-center"
on:click={() => {
hiddenColumns = [...hiddenColumns, key]
}}
>
<EyeIcon
size="16"
class="hover:text-gray-600 text-gray-400 rounded-full "
/>
</button>
{/if}
{#if isSortable(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'
}
}}
disabled={hiddenColumns.includes(key)}
>
{#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'
}
}}
disabled={hiddenColumns.includes(key)}
>
<MoveVertical size="16" class=" hover:text-gray-600 text-gray-400" />
</button>
{/if}
{/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 Object.keys(rowData ?? {}) ?? [] as key, index}
{@const value = rowData[key]}
<Cell last={index == Object.values(rowData ?? {}).length - 1}>
{#if hiddenColumns.includes(key)}
...
{:else if Array.isArray(value) && typeof value[0] === 'string'}
<div class="flex flex-row gap-1 w-full max-w-80 flex-wrap min-w-80">
{#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)}
<div class="flex flex-row gap-1 w-full max-w-80 flex-wrap min-w-80">
{#each value as val}
<div class="p-2 bg-surface-secondary rounded-md text-2xs">
{JSON.stringify(val)}
</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}
<Popover
placement="bottom"
notClickable
disablePopup={typeof value === 'string' && value.length < 50}
>
<div
class="max-w-80 text-wrap whitespace-pre-wrap flex flex-grow w-max three-lines cursor-text"
>
{value}
</div>
<svelte:fragment slot="text">{value}</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>
@@ -28,41 +28,47 @@
</script>
<div class={twMerge('border h-full overflow-auto', rounded ? 'rounded-md' : '')}>
<table class={twMerge('min-w-full divide-y')}>
<slot />
</table>
<div class={twMerge('overflow-auto')}>
<table class={twMerge('min-w-full divide-y')}>
<slot />
</table>
</div>
{#if paginated && !shouldHidePagination}
<div
class="bg-surface border-t flex flex-row justify-end p-1 items-center gap-2 sticky bottom-0"
>
<span class="text-xs">Page: {currentPage}</span>
<div class="flex flex-row gap-2 items-center">
<span class="text-xs">Page: {currentPage}</span>
{#if perPage !== undefined}
<select class="!text-xs !w-16" bind:value={perPage}>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
{/if}
<Button
color="light"
size="xs2"
on:click={() => dispatch('previous')}
disabled={currentPage === 1}
startIcon={{ icon: ArrowLeftIcon }}
>
Previous
</Button>
{#if showNext}
{#if perPage !== undefined}
<select class="!text-xs !w-16" bind:value={perPage}>
<option value={5}>5</option>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
{/if}
<Button
color="light"
size="xs2"
on:click={() => dispatch('next')}
endIcon={{ icon: ArrowRightIcon }}
on:click={() => dispatch('previous')}
disabled={currentPage === 1}
startIcon={{ icon: ArrowLeftIcon }}
>
Next
Previous
</Button>
{/if}
{#if showNext}
<Button
color="light"
size="xs2"
on:click={() => dispatch('next')}
endIcon={{ icon: ArrowRightIcon }}
>
Next
</Button>
{/if}
</div>
</div>
{:else if shouldLoadMore}
<div class="bg-surface border-t flex flex-row justify-center py-4 items-center gap-2">
+3 -1
View File
@@ -4,6 +4,7 @@
export let hoverable: boolean = false
export let selected: boolean = false
export let dividable: boolean = false
const dispatch = createEventDispatcher()
</script>
@@ -11,7 +12,8 @@
class={twMerge(
hoverable ? 'hover:bg-surface-hover cursor-pointer' : '',
selected ? 'bg-blue-50 dark:bg-blue-900/50' : '',
'transition-all'
'transition-all',
dividable ? 'divide-x' : ''
)}
on:click={() => {
dispatch('click')
@@ -0,0 +1,7 @@
export function isLink(value: string) {
return value?.startsWith('http://') || value?.startsWith('https://')
}
export function isEmail(value: string) {
return value?.includes('@')
}
@@ -519,7 +519,7 @@
<Skeleton loading={!job} layout={[[5]]} />
{#if job}
<div class="flex flex-row border rounded-md p-2 mt-2 max-h-1/2 overflow-auto">
<div class="flex flex-row border rounded-md p-2 mt-2 overflow-auto">
{#if viewTab == 'logs'}
<div class="w-full">
<LogViewer