mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 00:02:23 +00:00
feat(frontend): Add List pagination + add loading state in tables (#2096)
* feat(frontend): Add List pagination + add loading state in tables * feat(frontend): Use the same configuration as the Table, and managed pagination properly * feat(frontend): Fix wording + update default code + preconnect page output * feat(frontend): Fix wording + update default code + preconnect page output * feat(frontend): fix default code * feat(frontend): add comment in the default code to explain what the page parameter is * feat(frontend): revert changes
This commit is contained in:
@@ -65,6 +65,7 @@
|
||||
})
|
||||
|
||||
let inputs = {}
|
||||
let loading: boolean = false
|
||||
|
||||
$: setSearch(searchValue)
|
||||
|
||||
@@ -251,7 +252,15 @@
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<RunnableWrapper {outputs} {render} {componentInput} {id} bind:initializing bind:result>
|
||||
<RunnableWrapper
|
||||
{outputs}
|
||||
{render}
|
||||
{componentInput}
|
||||
{id}
|
||||
bind:initializing
|
||||
bind:result
|
||||
bind:loading
|
||||
>
|
||||
{#if Array.isArray(result) && result.every(isObject)}
|
||||
<div
|
||||
class={twMerge(
|
||||
@@ -528,6 +537,7 @@
|
||||
{table}
|
||||
class={css?.tableFooter?.class}
|
||||
style={css?.tableFooter?.style}
|
||||
{loading}
|
||||
/>
|
||||
</div>
|
||||
{:else if result != undefined}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { faDownload } from '@fortawesome/free-solid-svg-icons'
|
||||
import type { Table } from '@tanstack/svelte-table'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-svelte'
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-svelte'
|
||||
import type { Readable } from 'svelte/store'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
export { c as class }
|
||||
export let style = ''
|
||||
export let download: boolean = true
|
||||
export let loading: boolean = false
|
||||
|
||||
function convertJSONToCSV(objArray: Record<string, any>[]) {
|
||||
let str = ''
|
||||
@@ -45,6 +46,14 @@
|
||||
downloadAnchorNode.click()
|
||||
downloadAnchorNode.remove()
|
||||
}
|
||||
|
||||
let isPreviousLoading = false
|
||||
let isNextLoading = false
|
||||
|
||||
$: if (!loading) {
|
||||
isPreviousLoading = false
|
||||
isNextLoading = false
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if result.length > pageSize || manualPagination || download}
|
||||
@@ -55,26 +64,43 @@
|
||||
{#if result.length > pageSize || manualPagination}
|
||||
<div class="flex items-center gap-2 flex-row">
|
||||
<Button
|
||||
size="xs"
|
||||
size="xs2"
|
||||
variant="border"
|
||||
color="light"
|
||||
btnClasses="!py-1 !pl-1"
|
||||
on:click={() => $table.previousPage()}
|
||||
on:click={() => {
|
||||
isPreviousLoading = true
|
||||
$table.previousPage()
|
||||
}}
|
||||
disabled={!$table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
Previous
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
{#if isPreviousLoading && loading}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<ChevronLeft size={14} />
|
||||
{/if}
|
||||
Previous
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
size="xs2"
|
||||
variant="border"
|
||||
color="light"
|
||||
btnClasses="!py-1 !pr-1"
|
||||
on:click={() => $table.nextPage()}
|
||||
on:click={() => {
|
||||
isNextLoading = true
|
||||
$table.nextPage()
|
||||
}}
|
||||
disabled={!$table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={14} />
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
Next
|
||||
|
||||
{#if isNextLoading && loading}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<ChevronRight size={14} />
|
||||
{/if}
|
||||
</div>
|
||||
</Button>
|
||||
{$table.getState().pagination.pageIndex + 1} of {$table.getPageCount()}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
import ResolveConfig from '../helpers/ResolveConfig.svelte'
|
||||
import { components } from '../../editor/component'
|
||||
import RunnableWrapper from '../helpers/RunnableWrapper.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Loader2, ChevronLeft, ChevronRight } from 'lucide-svelte'
|
||||
|
||||
export let id: string
|
||||
export let componentInput: AppInput | undefined
|
||||
@@ -20,11 +22,13 @@
|
||||
|
||||
const { app, focusedGrid, selectedComponent, worldStore, connectingInput } =
|
||||
getContext<AppViewerContext>('AppViewerContext')
|
||||
let page = 0
|
||||
|
||||
const outputs = initOutput($worldStore, id, {
|
||||
result: undefined,
|
||||
loading: false,
|
||||
inputs: {}
|
||||
inputs: {},
|
||||
page: 0
|
||||
})
|
||||
|
||||
let resolvedConfig = initConfig(
|
||||
@@ -45,6 +49,57 @@
|
||||
$: isCard = resolvedConfig.width?.selected == 'card'
|
||||
|
||||
let inputs = {}
|
||||
let loading: boolean = false
|
||||
let isPreviousLoading = false
|
||||
let isNextLoading = false
|
||||
|
||||
$: if (!loading) {
|
||||
isPreviousLoading = false
|
||||
isNextLoading = false
|
||||
}
|
||||
|
||||
function getPagination(
|
||||
configuration: {
|
||||
auto: { pageSize: number | undefined }
|
||||
manual: { pageCount: number | undefined }
|
||||
},
|
||||
mode: 'auto' | 'manual' = 'auto',
|
||||
initialData: Array<any> | undefined = [],
|
||||
page: number = 0
|
||||
) {
|
||||
if (mode === 'auto') {
|
||||
const pageSize: number = configuration.auto.pageSize ?? 0
|
||||
const data = initialData?.slice(0 + page * pageSize, pageSize + page * pageSize) ?? []
|
||||
const shouldDisplayPagination = pageSize < initialData?.length ?? false
|
||||
const total = Math.ceil(initialData?.length / pageSize ?? 0)
|
||||
|
||||
return {
|
||||
data,
|
||||
shouldDisplayPagination,
|
||||
indexOffset: page * pageSize,
|
||||
disableNext: pageSize > 0 && (page + 1) * pageSize >= initialData?.length,
|
||||
total: total
|
||||
}
|
||||
} else {
|
||||
const pageCount = configuration.manual.pageCount ?? 0
|
||||
const total = pageCount
|
||||
|
||||
return {
|
||||
shouldDisplayPagination: true,
|
||||
data: initialData ?? [],
|
||||
indexOffset: 0,
|
||||
disableNext: page + 1 >= pageCount,
|
||||
total: total
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: pagination = getPagination(
|
||||
resolvedConfig.pagination?.configuration,
|
||||
resolvedConfig.pagination?.selected,
|
||||
result,
|
||||
page
|
||||
)
|
||||
</script>
|
||||
|
||||
{#each Object.keys(components['listcomponent'].initialData.configuration) as key (key)}
|
||||
@@ -66,54 +121,103 @@
|
||||
{id}
|
||||
bind:initializing
|
||||
bind:result
|
||||
bind:loading
|
||||
>
|
||||
<div
|
||||
class="w-full flex flex-wrap overflow-auto {isCard ? 'h-full gap-2' : 'divide-y max-h-full'}"
|
||||
>
|
||||
{#if $app.subgrids?.[`${id}-0`]}
|
||||
{#if Array.isArray(result) && result.length > 0}
|
||||
{#each result ?? [] as value, index}
|
||||
<div
|
||||
style={`${
|
||||
isCard
|
||||
? `min-width: ${resolvedConfig.width?.configuration?.card?.minWidthPx}px; `
|
||||
: ''
|
||||
} max-height: ${resolvedConfig.heightPx}px;`}
|
||||
class="overflow-auto {!isCard ? 'w-full' : 'border'}"
|
||||
>
|
||||
<ListWrapper
|
||||
on:inputsChange={() => {
|
||||
outputs?.inputs.set(inputs, true)
|
||||
}}
|
||||
bind:inputs
|
||||
{value}
|
||||
{index}
|
||||
<div class="flex flex-col divide-y h-full">
|
||||
<div
|
||||
class="w-full flex flex-wrap overflow-auto {isCard ? 'h-full gap-2' : 'divide-y max-h-full'}"
|
||||
>
|
||||
{#if $app.subgrids?.[`${id}-0`]}
|
||||
{#if Array.isArray(pagination.data) && pagination.data.length > 0}
|
||||
{#each pagination?.data ?? [] as value, index}
|
||||
<div
|
||||
style={`${
|
||||
isCard
|
||||
? `min-width: ${resolvedConfig.width?.configuration?.card?.minWidthPx}px; `
|
||||
: ''
|
||||
} max-height: ${resolvedConfig.heightPx}px;`}
|
||||
class="overflow-auto {!isCard ? 'w-full' : 'border'}"
|
||||
>
|
||||
<SubGridEditor
|
||||
visible={render}
|
||||
{id}
|
||||
class={css?.container?.class}
|
||||
style={css?.container?.style}
|
||||
subGridId={`${id}-0`}
|
||||
containerHeight={resolvedConfig.heightPx}
|
||||
on:focus={() => {
|
||||
if (!$connectingInput.opened) {
|
||||
$selectedComponent = [id]
|
||||
}
|
||||
onFocus()
|
||||
<ListWrapper
|
||||
on:inputsChange={() => {
|
||||
outputs?.inputs.set(inputs, true)
|
||||
}}
|
||||
/>
|
||||
</ListWrapper>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<ListWrapper disabled value={undefined} index={0}>
|
||||
<SubGridEditor visible={false} {id} subGridId={`${id}-0`} />
|
||||
</ListWrapper>
|
||||
{#if !Array.isArray(result)}
|
||||
<div class="text-center text-tertiary">Input data is not an array</div>
|
||||
bind:inputs
|
||||
{value}
|
||||
index={index + pagination.indexOffset}
|
||||
>
|
||||
<SubGridEditor
|
||||
visible={render}
|
||||
{id}
|
||||
class={css?.container?.class}
|
||||
style={css?.container?.style}
|
||||
subGridId={`${id}-0`}
|
||||
containerHeight={resolvedConfig.heightPx}
|
||||
on:focus={() => {
|
||||
if (!$connectingInput.opened) {
|
||||
$selectedComponent = [id]
|
||||
}
|
||||
onFocus()
|
||||
}}
|
||||
/>
|
||||
</ListWrapper>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<ListWrapper disabled value={undefined} index={0}>
|
||||
<SubGridEditor visible={false} {id} subGridId={`${id}-0`} />
|
||||
</ListWrapper>
|
||||
{#if !Array.isArray(result)}
|
||||
<div class="text-center text-tertiary">Input data is not an array</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if pagination.shouldDisplayPagination}
|
||||
<div class="bg-surface-secondary h-8 flex flex-row gap-1 p-1 items-center">
|
||||
<Button
|
||||
size="xs2"
|
||||
variant="border"
|
||||
color="light"
|
||||
on:click={() => {
|
||||
isPreviousLoading = true
|
||||
page = page - 1
|
||||
outputs?.page.set(page, true)
|
||||
}}
|
||||
disabled={page === 0}
|
||||
>
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
{#if isPreviousLoading && loading}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<ChevronLeft size={14} />
|
||||
{/if}
|
||||
Previous
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs2"
|
||||
variant="border"
|
||||
color="light"
|
||||
on:click={() => {
|
||||
isNextLoading = true
|
||||
page = page + 1
|
||||
outputs?.page.set(page, true)
|
||||
}}
|
||||
disabled={pagination.disableNext}
|
||||
>
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
Next
|
||||
|
||||
{#if isNextLoading && loading}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<ChevronRight size={14} />
|
||||
{/if}
|
||||
</div>
|
||||
</Button>
|
||||
<div class="text-xs">{page + 1} of {pagination.total}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</RunnableWrapper>
|
||||
|
||||
@@ -431,9 +431,11 @@ const paginationOneOf = {
|
||||
type: 'oneOf',
|
||||
selected: 'auto',
|
||||
labels: {
|
||||
auto: 'Auto',
|
||||
manual: 'Manual'
|
||||
auto: 'Managed by component',
|
||||
manual: 'Managed by runnable'
|
||||
},
|
||||
tooltip:
|
||||
'Pagination can be managed using two methods: By the component: Based on a specified page size, the component divides the array into several pages. By the runnable: The component shows all items, leaving the task of pagination to the runnable. The current page number is available in the component outputs.',
|
||||
configuration: {
|
||||
auto: {
|
||||
pageSize: {
|
||||
@@ -603,7 +605,36 @@ export const components = {
|
||||
fieldType: 'number',
|
||||
value: 280,
|
||||
tooltip: 'Height in pixels'
|
||||
}
|
||||
},
|
||||
|
||||
pagination: {
|
||||
type: 'oneOf',
|
||||
selected: 'auto',
|
||||
labels: {
|
||||
auto: 'Managed by component',
|
||||
manual: 'Managed by runnable'
|
||||
},
|
||||
tooltip:
|
||||
'Pagination can be managed using two methods: By the component: Based on a specified page size, the component divides the array into several pages. By the runnable: The component shows all items, leaving the task of pagination to the runnable. The current page number is available in the component outputs.',
|
||||
configuration: {
|
||||
manual: {
|
||||
pageCount: {
|
||||
type: 'static',
|
||||
fieldType: 'number',
|
||||
value: -1,
|
||||
tooltip: 'Number of pages (-1 if you do not know)'
|
||||
}
|
||||
},
|
||||
auto: {
|
||||
pageSize: {
|
||||
type: 'static',
|
||||
fieldType: 'number',
|
||||
value: 20,
|
||||
tooltip: 'Number of items per page'
|
||||
}
|
||||
}
|
||||
}
|
||||
} as const
|
||||
},
|
||||
componentInput: {
|
||||
type: 'static',
|
||||
|
||||
@@ -530,5 +530,18 @@ return {
|
||||
"required": []
|
||||
}
|
||||
`
|
||||
},
|
||||
listcomponent: {
|
||||
deno: `export async function main() {
|
||||
return [{
|
||||
"foo": 1,
|
||||
}, {
|
||||
"foo": 2,
|
||||
}, {
|
||||
"foo": 3,
|
||||
}];
|
||||
}`,
|
||||
python3: `def main():
|
||||
return [{"foo": 1}, {"foo": 2}, {"foo": 3}]`
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="mb-2 text-sm font-semibold">
|
||||
{capitalize(addWhitespaceBeforeCapitals(key))}
|
||||
{#if tooltip}
|
||||
<Tooltip>{tooltip}</Tooltip>
|
||||
<Tooltip light>{tooltip}</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
<select
|
||||
@@ -69,6 +69,7 @@
|
||||
onlyStatic={config?.['onlyStatic']}
|
||||
customTitle={config?.['customTitle']}
|
||||
noVariablePicker={config?.['noVariablePicker']}
|
||||
tooltip={config?.['tooltip']}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user