mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 08:02:19 +00:00
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:
Generated
+4
-4
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user