mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-08-17 08:00:34 +02:00
improve table loading ux
Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
<script>
|
||||
export let center = false;
|
||||
export let square = false;
|
||||
// deterministic placeholder width so the skeleton keeps the same calm shape
|
||||
// across reloads instead of reshuffling on every mount
|
||||
export let index = 0;
|
||||
const widths = ['w-16', 'w-20', 'w-24'];
|
||||
let height = 'h-4';
|
||||
let width = widths[Math.floor(Math.random() * widths.length)];
|
||||
let width = widths[index % widths.length];
|
||||
let gradient = 'from-gray-300 dark:from-gray-600 to-transparent bg-gradient-to-r';
|
||||
if (square) {
|
||||
width = 'h-4';
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import TableHeader from './TableHeader.svelte';
|
||||
import Search from '$lib/components/Search.svelte';
|
||||
import Select from '$lib/components/Select.svelte';
|
||||
import { afterUpdate, onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import TableCell from './TableCell.svelte';
|
||||
import TableRow from './TableRow.svelte';
|
||||
import TableCellEmpty from './TableCellEmpty.svelte';
|
||||
@@ -32,21 +32,57 @@
|
||||
/** @type {'none'|'some'|'all'} */
|
||||
export let headerState = 'none';
|
||||
|
||||
let tableWrapper = null;
|
||||
let columnsLength = columns.length;
|
||||
// wait this long before revealing any loading state, so quick loads never
|
||||
// flash an indicator; the moment the load finishes we drop it again, so an
|
||||
// instant load leaves no lingering dim
|
||||
const LOADING_DELAY_MS = 150;
|
||||
|
||||
let rowsLength = 0;
|
||||
// busy is the delayed view of isGhost: only true for loads slower than the delay
|
||||
let busy = false;
|
||||
// stays true once real rows have been on screen, so later loads dim the
|
||||
// existing rows in place instead of tearing them down for a skeleton
|
||||
let hasRenderedData = false;
|
||||
let showTimer = null;
|
||||
|
||||
afterUpdate(() => {
|
||||
const elements = tableWrapper?.querySelectorAll('table > tr.table-row');
|
||||
rowsLength = elements?.length ?? 0;
|
||||
});
|
||||
const startBusy = () => {
|
||||
if (busy || showTimer) {
|
||||
return;
|
||||
}
|
||||
showTimer = setTimeout(() => {
|
||||
showTimer = null;
|
||||
busy = true;
|
||||
}, LOADING_DELAY_MS);
|
||||
};
|
||||
|
||||
const stopBusy = () => {
|
||||
if (showTimer) {
|
||||
clearTimeout(showTimer);
|
||||
showTimer = null;
|
||||
}
|
||||
busy = false;
|
||||
};
|
||||
|
||||
$: isGhost ? startBusy() : stopBusy();
|
||||
$: if (hasData) {
|
||||
hasRenderedData = true;
|
||||
}
|
||||
|
||||
// first load with nothing on screen yet shows the skeleton; any later load
|
||||
// keeps the current rows visible and just dims them
|
||||
$: showSkeleton = busy && !hasRenderedData;
|
||||
$: dimRows = busy && hasRenderedData;
|
||||
|
||||
$: columnsLength = columns.length + (hasActions ? 2 : 0) + (selectable ? 1 : 0);
|
||||
$: skeletonRows = pagination?.perPage || 10;
|
||||
|
||||
onMount(() => {
|
||||
if (!pagination && sortable?.length) {
|
||||
console.warn('You need to pass a pagination object to make the column sortable');
|
||||
}
|
||||
columnsLength = columns.length + (hasActions ? 2 : 0) + (selectable ? 1 : 0);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(showTimer);
|
||||
});
|
||||
|
||||
let currentPage = pagination && pagination.currentPage;
|
||||
@@ -63,50 +99,56 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
bind:this={tableWrapper}
|
||||
class="
|
||||
border-2 border-gray-200 dark:border-gray-700/60 rounded-md px-4 py-4 overflow-x-auto bg-white dark:bg-gray-900/80 transition-colors duration-200
|
||||
{scrollBarClassesHorizontal}"
|
||||
>
|
||||
<table
|
||||
class="w-full table-fixed bg-white dark:bg-gray-900/80 transition-colors duration-200"
|
||||
class:animate-pulse={isGhost}
|
||||
class:animate-pulse={showSkeleton}
|
||||
>
|
||||
<TableHeader
|
||||
{isGhost}
|
||||
isGhost={showSkeleton}
|
||||
{columns}
|
||||
{sortable}
|
||||
{hasActions}
|
||||
{pagination}
|
||||
{selectable}
|
||||
{headerState}
|
||||
showSelect={hasData}
|
||||
on:toggleAll
|
||||
/>
|
||||
{#if !hasData && !isGhost}
|
||||
<EmptyTableResult page={currentPage} {plural} colspan={columnsLength} />
|
||||
{/if}
|
||||
{#if !isGhost}
|
||||
<slot />
|
||||
{:else}
|
||||
{#each Array(rowsLength || pagination?.perPage) as _, row}
|
||||
<TableRow>
|
||||
{#if selectable}
|
||||
<TableCellEmpty />
|
||||
{/if}
|
||||
{#each columns as column}
|
||||
<TableCell>
|
||||
<GhostText />
|
||||
</TableCell>
|
||||
{/each}
|
||||
{#if hasActions}
|
||||
<TableCellEmpty />
|
||||
<TableCellAction>
|
||||
<GhostText square center />
|
||||
</TableCellAction>
|
||||
{/if}
|
||||
</TableRow>
|
||||
{/each}
|
||||
{/if}
|
||||
<tbody
|
||||
class="transition-opacity duration-200"
|
||||
class:animate-pulse={dimRows}
|
||||
class:pointer-events-none={dimRows}
|
||||
>
|
||||
{#if showSkeleton}
|
||||
{#each Array(skeletonRows) as _, row}
|
||||
<TableRow>
|
||||
{#if selectable}
|
||||
<TableCellEmpty />
|
||||
{/if}
|
||||
{#each columns as column, col}
|
||||
<TableCell>
|
||||
<GhostText index={row * columns.length + col} />
|
||||
</TableCell>
|
||||
{/each}
|
||||
{#if hasActions}
|
||||
<TableCellEmpty />
|
||||
<TableCellAction>
|
||||
<GhostText square center />
|
||||
</TableCellAction>
|
||||
{/if}
|
||||
</TableRow>
|
||||
{/each}
|
||||
{:else}
|
||||
{#if !hasData}
|
||||
<EmptyTableResult page={currentPage} {plural} colspan={columnsLength} />
|
||||
{/if}
|
||||
<slot />
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if pagination}
|
||||
|
||||
@@ -3,11 +3,17 @@
|
||||
|
||||
/** @type {'none'|'some'|'all'} */
|
||||
export let state = 'none';
|
||||
// non-interactive placeholder used while the table skeleton is showing, so
|
||||
// the checkbox column keeps its width and the columns do not shift on load
|
||||
export let disabled = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// clicking selects every page row unless they are all selected already
|
||||
const onToggle = () => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
dispatch('toggleAll', state !== 'all');
|
||||
};
|
||||
</script>
|
||||
@@ -15,13 +21,18 @@
|
||||
<th
|
||||
class="pl-4 w-12 bg-grayblue-light dark:bg-gray-800/60 py-4 border-hidden rounded-tl-lg rounded-bl-lg transition-colors duration-200"
|
||||
>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<label
|
||||
class="relative inline-flex items-center"
|
||||
class:cursor-pointer={!disabled}
|
||||
class:opacity-0={disabled}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer sr-only"
|
||||
checked={state === 'all'}
|
||||
{disabled}
|
||||
on:change={onToggle}
|
||||
tabindex="0"
|
||||
tabindex={disabled ? -1 : 0}
|
||||
/>
|
||||
<div
|
||||
class="w-5 h-5 border-2 border-slate-300 dark:border-gray-700/60 rounded
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
export let selectable = false;
|
||||
/** @type {'none'|'some'|'all'} */
|
||||
export let headerState = 'none';
|
||||
// the checkbox column keeps its width whenever selectable is set; showSelect
|
||||
// controls whether the select all control is actually shown, so an empty or
|
||||
// loading table reserves the gutter without a dangling control
|
||||
export let showSelect = false;
|
||||
|
||||
$: sortableMap = {};
|
||||
|
||||
@@ -31,11 +35,7 @@
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{#if selectable}
|
||||
{#if !isGhost}
|
||||
<TableHeadCellCheckbox state={headerState} on:toggleAll />
|
||||
{:else}
|
||||
<TableHeadCellEmpty />
|
||||
{/if}
|
||||
<TableHeadCellCheckbox state={headerState} disabled={isGhost || !showSelect} on:toggleAll />
|
||||
{/if}
|
||||
{#each columns as column, i (typeof column === 'object' ? column.column : column)}
|
||||
{#if typeof column === 'object'}
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
.filter((s) => !globalButtonDisabledAttributes(s, contextCompanyID).disabled)
|
||||
.map((s) => s.id);
|
||||
$: headerState = headerSelectionState($selection, selectablePageIds);
|
||||
$: showMultiSelect = selectablePageIds.length > 1;
|
||||
$: showMultiSelect = apiSenders.length > 0;
|
||||
const onClickBulkDelete = async () => {
|
||||
await runBulkDelete({
|
||||
ids: [...$selection],
|
||||
@@ -373,7 +373,7 @@
|
||||
on:clear={() => selection.clear()}
|
||||
/>
|
||||
<Table
|
||||
selectable={showMultiSelect}
|
||||
selectable
|
||||
{headerState}
|
||||
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
|
||||
columns={[
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
.filter((a) => !globalButtonDisabledAttributes(a, contextCompanyID).disabled)
|
||||
.map((a) => a.id);
|
||||
$: headerState = headerSelectionState($selection, selectablePageIds);
|
||||
$: showMultiSelect = selectablePageIds.length > 1;
|
||||
$: showMultiSelect = assets.length > 0;
|
||||
const onClickBulkDelete = async () => {
|
||||
await runBulkDelete({ ids: [...$selection], deleteFn: api.asset.delete, noun: 'asset' });
|
||||
await refreshAssets();
|
||||
@@ -461,7 +461,7 @@
|
||||
on:clear={() => selection.clear()}
|
||||
/>
|
||||
<Table
|
||||
selectable={showMultiSelect}
|
||||
selectable
|
||||
{headerState}
|
||||
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
|
||||
columns={[
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
.filter((a) => !globalButtonDisabledAttributes(a, contextCompanyID).disabled)
|
||||
.map((a) => a.id);
|
||||
$: headerState = headerSelectionState($selection, selectablePageIds);
|
||||
$: showMultiSelect = selectablePageIds.length > 1;
|
||||
$: showMultiSelect = attachments.length > 0;
|
||||
const onClickBulkDelete = async () => {
|
||||
await runBulkDelete({
|
||||
ids: [...$selection],
|
||||
@@ -303,7 +303,7 @@
|
||||
on:clear={() => selection.clear()}
|
||||
/>
|
||||
<Table
|
||||
selectable={showMultiSelect}
|
||||
selectable
|
||||
{headerState}
|
||||
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
|
||||
columns={[
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
.filter((t) => !globalButtonDisabledAttributes(t, contextCompanyID).disabled)
|
||||
.map((t) => t.id);
|
||||
$: headerState = headerSelectionState($selection, selectablePageIds);
|
||||
$: showMultiSelect = selectablePageIds.length > 1;
|
||||
$: showMultiSelect = templates.length > 0;
|
||||
const onClickBulkDelete = async () => {
|
||||
await runBulkDelete({
|
||||
ids: [...$selection],
|
||||
@@ -661,7 +661,7 @@
|
||||
on:clear={() => selection.clear()}
|
||||
/>
|
||||
<Table
|
||||
selectable={showMultiSelect}
|
||||
selectable
|
||||
{headerState}
|
||||
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
|
||||
columns={[
|
||||
|
||||
@@ -254,7 +254,7 @@
|
||||
.filter((c) => !globalButtonDisabledAttributes(c, contextCompanyID).disabled)
|
||||
.map((c) => c.id);
|
||||
$: headerState = headerSelectionState($selection, selectablePageIds);
|
||||
$: showMultiSelect = selectablePageIds.length > 1;
|
||||
$: showMultiSelect = (campaigns ?? []).length > 0;
|
||||
const onClickBulkDelete = async () => {
|
||||
await runBulkDelete({ ids: [...$selection], deleteFn: api.campaign.delete, noun: 'campaign' });
|
||||
await refreshCampaigns();
|
||||
@@ -1582,7 +1582,7 @@
|
||||
on:clear={() => selection.clear()}
|
||||
/>
|
||||
<Table
|
||||
selectable={showMultiSelect}
|
||||
selectable
|
||||
{headerState}
|
||||
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
|
||||
columns={[
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
.filter((e) => !globalButtonDisabledAttributes(e, contextCompanyID).disabled)
|
||||
.map((e) => e.id);
|
||||
$: headerState = headerSelectionState($selection, selectablePageIds);
|
||||
$: showMultiSelect = selectablePageIds.length > 1;
|
||||
$: showMultiSelect = emails.length > 0;
|
||||
const onClickBulkDelete = async () => {
|
||||
await runBulkDelete({ ids: [...$selection], deleteFn: api.email.delete, noun: 'email' });
|
||||
await refreshEmails();
|
||||
@@ -414,7 +414,7 @@
|
||||
on:clear={() => selection.clear()}
|
||||
/>
|
||||
<Table
|
||||
selectable={showMultiSelect}
|
||||
selectable
|
||||
{headerState}
|
||||
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
|
||||
columns={[
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
.filter((entry) => !globalButtonDisabledAttributes(entry, contextCompanyID).disabled)
|
||||
.map((entry) => entry.id);
|
||||
$: headerState = headerSelectionState($selection, selectablePageIds);
|
||||
$: showMultiSelect = selectablePageIds.length > 1;
|
||||
$: showMultiSelect = allowDenyList.length > 0;
|
||||
const onClickBulkDelete = async () => {
|
||||
await runBulkDelete({ ids: [...$selection], deleteFn: api.allowDeny.delete, noun: 'filter' });
|
||||
await refreshAllowDenies();
|
||||
@@ -430,7 +430,7 @@
|
||||
on:clear={() => selection.clear()}
|
||||
/>
|
||||
<Table
|
||||
selectable={showMultiSelect}
|
||||
selectable
|
||||
{headerState}
|
||||
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
|
||||
columns={[
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
.filter((p) => !globalButtonDisabledAttributes(p, contextCompanyID).disabled)
|
||||
.map((p) => p.id);
|
||||
$: headerState = headerSelectionState($selection, selectablePageIds);
|
||||
$: showMultiSelect = selectablePageIds.length > 1;
|
||||
$: showMultiSelect = providers.length > 0;
|
||||
const onClickBulkDelete = async () => {
|
||||
await runBulkDelete({
|
||||
ids: [...$selection],
|
||||
@@ -530,7 +530,7 @@
|
||||
on:clear={() => selection.clear()}
|
||||
/>
|
||||
<Table
|
||||
selectable={showMultiSelect}
|
||||
selectable
|
||||
{headerState}
|
||||
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
|
||||
columns={['Name', 'Status']}
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
.filter((p) => !globalButtonDisabledAttributes(p, contextCompanyID).disabled)
|
||||
.map((p) => p.id);
|
||||
$: headerState = headerSelectionState($selection, selectablePageIds);
|
||||
$: showMultiSelect = selectablePageIds.length > 1;
|
||||
$: showMultiSelect = pages.length > 0;
|
||||
const onClickBulkDelete = async () => {
|
||||
await runBulkDelete({ ids: [...$selection], deleteFn: api.page.delete, noun: 'page' });
|
||||
await refreshPages();
|
||||
@@ -354,7 +354,7 @@
|
||||
on:clear={() => selection.clear()}
|
||||
/>
|
||||
<Table
|
||||
selectable={showMultiSelect}
|
||||
selectable
|
||||
{headerState}
|
||||
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
|
||||
columns={[
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
.filter((p) => !globalButtonDisabledAttributes(p, contextCompanyID).disabled)
|
||||
.map((p) => p.id);
|
||||
$: headerState = headerSelectionState($selection, selectablePageIds);
|
||||
$: showMultiSelect = selectablePageIds.length > 1;
|
||||
$: showMultiSelect = proxies.length > 0;
|
||||
const onClickBulkDelete = async () => {
|
||||
await runBulkDelete({
|
||||
ids: [...$selection],
|
||||
@@ -686,7 +686,7 @@ portal.example.com:
|
||||
on:clear={() => selection.clear()}
|
||||
/>
|
||||
<Table
|
||||
selectable={showMultiSelect}
|
||||
selectable
|
||||
{headerState}
|
||||
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
|
||||
columns={[
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
.filter((r) => !globalButtonDisabledAttributes(r, contextCompanyID).disabled)
|
||||
.map((r) => r.id);
|
||||
$: headerState = headerSelectionState($selection, selectablePageIds);
|
||||
$: showMultiSelect = selectablePageIds.length > 1;
|
||||
$: showMultiSelect = recipients.length > 0;
|
||||
|
||||
let isRecipientsTableLoading = false;
|
||||
|
||||
@@ -465,7 +465,7 @@
|
||||
/>
|
||||
<Table
|
||||
isGhost={isRecipientsTableLoading}
|
||||
selectable={showMultiSelect}
|
||||
selectable
|
||||
{headerState}
|
||||
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
|
||||
columns={[
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
.filter((g) => !globalButtonDisabledAttributes(g, contextCompanyID).disabled)
|
||||
.map((g) => g.id);
|
||||
$: headerState = headerSelectionState($selection, selectablePageIds);
|
||||
$: showMultiSelect = selectablePageIds.length > 1;
|
||||
$: showMultiSelect = groups.length > 0;
|
||||
|
||||
// dynamic update modal state
|
||||
let isDynamicUpdateModalVisible = false;
|
||||
@@ -380,7 +380,7 @@
|
||||
on:clear={() => selection.clear()}
|
||||
/>
|
||||
<Table
|
||||
selectable={showMultiSelect}
|
||||
selectable
|
||||
{headerState}
|
||||
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
|
||||
columns={[
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
.filter((c) => !globalButtonDisabledAttributes(c, contextCompanyID).disabled)
|
||||
.map((c) => c.id);
|
||||
$: headerState = headerSelectionState($selection, selectablePageIds);
|
||||
$: showMultiSelect = selectablePageIds.length > 1;
|
||||
$: showMultiSelect = configurations.length > 0;
|
||||
const onClickBulkDelete = async () => {
|
||||
await runBulkDelete({
|
||||
ids: [...$selection],
|
||||
@@ -456,7 +456,7 @@
|
||||
on:clear={() => selection.clear()}
|
||||
/>
|
||||
<Table
|
||||
selectable={showMultiSelect}
|
||||
selectable
|
||||
{headerState}
|
||||
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
|
||||
columns={[
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
.filter((w) => !globalButtonDisabledAttributes(w, contextCompanyID).disabled)
|
||||
.map((w) => w.id);
|
||||
$: headerState = headerSelectionState($selection, selectablePageIds);
|
||||
$: showMultiSelect = selectablePageIds.length > 1;
|
||||
$: showMultiSelect = webhooks.length > 0;
|
||||
const onClickBulkDelete = async () => {
|
||||
await runBulkDelete({ ids: [...$selection], deleteFn: api.webhook.delete, noun: 'webhook' });
|
||||
await refreshWebhooks();
|
||||
@@ -315,7 +315,7 @@
|
||||
on:clear={() => selection.clear()}
|
||||
/>
|
||||
<Table
|
||||
selectable={showMultiSelect}
|
||||
selectable
|
||||
{headerState}
|
||||
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
|
||||
columns={[
|
||||
|
||||
Reference in New Issue
Block a user