Skip to content

Commit

Permalink
Merge pull request #1005 from rainlanguage/move-tanstackapptable
Browse files Browse the repository at this point in the history
Move TanstackAppTable into ui-components and webapp
  • Loading branch information
hardyjosh authored Nov 20, 2024
2 parents cae4c78 + 2011dbe commit a3c840f
Show file tree
Hide file tree
Showing 35 changed files with 646 additions and 518 deletions.
70 changes: 70 additions & 0 deletions packages/ui-components/src/lib/components/TanstackAppTable.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<script lang="ts" generics="T">
import Refresh from './icon/Refresh.svelte';
import type { CreateInfiniteQueryResult, InfiniteData } from '@tanstack/svelte-query';
import { Button, Table, TableBody, TableBodyRow, TableHead } from 'flowbite-svelte';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
// eslint-disable-next-line no-undef
export let query: CreateInfiniteQueryResult<InfiniteData<T[], unknown>, Error>;
export let emptyMessage: string = 'None found';
export let rowHoverable = true;
</script>

<div data-testid="title" class="flex h-16 w-full items-center justify-end">
<slot name="info" />
<slot name="timeFilter" />
<slot name="title" />
<Refresh
data-testid="refreshButton"
class="ml-2 h-8 w-5 cursor-pointer text-gray-400 dark:text-gray-400"
spin={$query.isLoading || $query.isFetching}
on:click={() => {
$query.refetch();
}}
/>
</div>
{#if $query.data?.pages[0].length === 0}
<div data-testid="emptyMessage" class="text-center text-gray-900 dark:text-white">
{emptyMessage}
</div>
{:else if $query.data}
<Table
divClass="cursor-pointer rounded-lg overflow-hidden dark:border-none border"
hoverable={rowHoverable}
>
<TableHead data-testid="head">
<slot name="head" />
</TableHead>
<TableBody>
{#each $query.data?.pages as page}
{#each page as item}
<TableBodyRow
data-testid="bodyRow"
on:click={() => {
dispatch('clickRow', { item });
}}
>
<slot name="bodyRow" {item} />
</TableBodyRow>
{/each}
{/each}
</TableBody>
</Table>
<div class="mt-2 flex justify-center">
<Button
data-testid="loadMoreButton"
size="xs"
color="dark"
on:click={() => $query.fetchNextPage()}
disabled={!$query.hasNextPage || $query.isFetchingNextPage}
>
{#if $query.isFetchingNextPage}
Loading more...
{:else if $query.hasNextPage}
Load More
{:else}Nothing more to load{/if}
</Button>
</div>
{/if}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script lang="ts">
import TanstackAppTable from './TanstackAppTable.svelte';
export let query;
export let emptyMessage: string;
export let title: string;
export let head: string;
</script>

<TanstackAppTable {query} {emptyMessage} rowHoverable>
<svelte:fragment slot="title">
<h2>{title}</h2>
</svelte:fragment>
<svelte:fragment slot="head">{head}</svelte:fragment>
<svelte:fragment slot="bodyRow" let:item>
{item}
</svelte:fragment>
</TanstackAppTable>
237 changes: 237 additions & 0 deletions packages/ui-components/src/lib/components/TanstackAppTable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import { render, screen, waitFor } from '@testing-library/svelte';
import { test, expect } from 'vitest';
import TanstackAppTableTest from './TanstackAppTable.test.svelte';
import userEvent from '@testing-library/user-event';
import { createResolvableInfiniteQuery } from '../mocks/queries';

test('shows head and title', async () => {
const { query, resolve } = createResolvableInfiniteQuery((pageParam) => {
return ['page' + pageParam];
});

render(TanstackAppTableTest, {
query,
emptyMessage: 'No rows',
title: 'Test Table',
head: 'Test head'
});

resolve();

await waitFor(() => expect(screen.getByTestId('head')).toHaveTextContent('Test head'));
expect(screen.getByTestId('title')).toHaveTextContent('Test Table');
});

test('renders rows', async () => {
const { query, resolve } = createResolvableInfiniteQuery((pageParam) => {
return ['page' + pageParam];
});

render(TanstackAppTableTest, {
query,
emptyMessage: 'No rows',
title: 'Test Table',
head: 'Test head'
});

resolve();
await waitFor(() => expect(screen.getByTestId('bodyRow')).toHaveTextContent('page0'));
});

test('shows empty message', async () => {
const { query, resolve } = createResolvableInfiniteQuery(() => {
return [];
});

render(TanstackAppTableTest, {
query,
emptyMessage: 'No rows',
title: 'Test Table',
head: 'Test head'
});

resolve();

await waitFor(() => expect(screen.getByTestId('emptyMessage')).toHaveTextContent('No rows'));
});

test('loads more rows', async () => {
const { query, resolve } = createResolvableInfiniteQuery((pageParam) => {
return ['page' + pageParam];
});

render(TanstackAppTableTest, {
query,
emptyMessage: 'No rows',
title: 'Test Table',
head: 'Test head'
});

resolve();

await waitFor(() => expect(screen.getByTestId('bodyRow')).toHaveTextContent('page0'));

// loading more rows
const loadMoreButton = screen.getByTestId('loadMoreButton');
await userEvent.click(loadMoreButton);

resolve();

await waitFor(() => {
expect(screen.getAllByTestId('bodyRow')).toHaveLength(2);
});

let rows = screen.getAllByTestId('bodyRow');

expect(rows).toHaveLength(2);
expect(rows[0]).toHaveTextContent('page0');
expect(rows[1]).toHaveTextContent('page1');

// loading more rows
await userEvent.click(loadMoreButton);

resolve();

await waitFor(() => {
expect(screen.getAllByTestId('bodyRow')).toHaveLength(3);
});

rows = screen.getAllByTestId('bodyRow');

expect(rows).toHaveLength(3);
expect(rows[0]).toHaveTextContent('page0');
expect(rows[1]).toHaveTextContent('page1');
expect(rows[2]).toHaveTextContent('page2');
});

test('load more button message changes when loading', async () => {
const { query, resolve } = createResolvableInfiniteQuery((pageParam) => {
return ['page' + pageParam];
});

render(TanstackAppTableTest, {
query,
emptyMessage: 'No rows',
title: 'Test Table',
head: 'Test head'
});

resolve();

expect(await screen.findByTestId('loadMoreButton')).toHaveTextContent('Load More');

// loading more rows
const loadMoreButton = screen.getByTestId('loadMoreButton');
await userEvent.click(loadMoreButton);

expect(await screen.findByTestId('loadMoreButton')).toHaveTextContent('Loading more...');

resolve();

await waitFor(() => {
expect(screen.getByTestId('loadMoreButton')).toHaveTextContent('Load More');
});
});

test('load more buttton is disabled when loading', async () => {
const { query, resolve } = createResolvableInfiniteQuery((pageParam) => {
return ['page' + pageParam];
});

render(TanstackAppTableTest, {
query,
emptyMessage: 'No rows',
title: 'Test Table',
head: 'Test head'
});

resolve();

await waitFor(() => expect(screen.getByTestId('loadMoreButton')).not.toHaveAttribute('disabled'));

// loading more rows
const loadMoreButton = screen.getByTestId('loadMoreButton');
loadMoreButton.click();

await waitFor(() => expect(screen.getByTestId('loadMoreButton')).toHaveAttribute('disabled'));

resolve();

await waitFor(() => expect(screen.getByTestId('loadMoreButton')).not.toHaveAttribute('disabled'));
});

test('load more buttton is disabled when there are no more pages', async () => {
const { query, resolve } = createResolvableInfiniteQuery(
(pageParam) => {
if (!pageParam) return ['page' + pageParam];
return [];
},
(_lastPage, _allPages, lastPageParam) => {
if (lastPageParam === 0) return 1;
return undefined;
}
);

render(TanstackAppTableTest, {
query,
emptyMessage: 'No rows',
title: 'Test Table',
head: 'Test head'
});

resolve();

await waitFor(() => expect(screen.getByTestId('loadMoreButton')).not.toHaveAttribute('disabled'));

// loading more rows
const loadMoreButton = screen.getByTestId('loadMoreButton');
loadMoreButton.click();

await waitFor(() => expect(screen.getByTestId('loadMoreButton')).toHaveAttribute('disabled'));

resolve();

await waitFor(() =>
expect(screen.getByTestId('loadMoreButton')).toHaveTextContent('Nothing more to load')
);
});

test('refetches data when refresh button is clicked', async () => {
let refreshCount = 0;
const { query, resolve } = createResolvableInfiniteQuery(() => {
refreshCount++;
return ['refresh' + refreshCount];
});

render(TanstackAppTableTest, {
query,
emptyMessage: 'No rows',
title: 'Test Table',
head: 'Test head'
});

resolve();

await waitFor(() => expect(screen.getByTestId('bodyRow')).toHaveTextContent('refresh1'));

// refreshing
const refreshButton = screen.getByTestId('refreshButton');
await userEvent.click(refreshButton);

// refreshButton should have the class animate-spin
await waitFor(() => expect(refreshButton).toHaveClass('animate-spin'));

resolve();

await waitFor(() => expect(screen.getByTestId('bodyRow')).toHaveTextContent('refresh2'));

// refreshButton should not have the class animate-spin
await waitFor(() => expect(refreshButton).not.toHaveClass('animate-spin'));

// refreshing
await userEvent.click(refreshButton);

resolve();

await waitFor(() => expect(screen.getByTestId('bodyRow')).toHaveTextContent('refresh3'));
});
55 changes: 55 additions & 0 deletions packages/ui-components/src/lib/components/icon/Refresh.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { AriaRole } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
const ctx: { size: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; role: AriaRole | undefined } =
getContext('iconCtx') ?? {};
const sizes = {
xs: 'w-3 h-3',
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
xl: 'w-8 h-8'
};
export let size = ctx.size || 'md';
export let role = ctx.role || 'img';
export let ariaLabel = 'refresh';
export let spin = false;
</script>

<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
{...$$restProps}
class={twMerge('shrink-0', sizes[size], $$props.class, spin ? 'animate-spin' : '')}
{role}
aria-label={ariaLabel}
viewBox="0 0 24 24"
on:click
on:keydown
on:keyup
on:focus
on:blur
on:mouseenter
on:mouseleave
on:mouseover
on:mouseout
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.651 7.65a7.131 7.131 0 0 0-12.68 3.15M18.001 4v4h-4m-7.652 8.35a7.13 7.13 0 0 0 12.68-3.15M6 20v-4h4"
/>
</svg>

<!--
@component
[Go to docs](https://flowbite-svelte-icons.vercel.app/)
## Props
@prop export let size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = ctx.size || 'md';
@prop export let role = ctx.role || 'img';
@prop export let ariaLabel = 'refresh';
-->
12 changes: 12 additions & 0 deletions packages/ui-components/src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
import './app.css';
export { default as CardProperty } from './components/CardProperty.svelte';
export { default as Hash, HashType } from './components/Hash.svelte';
export { default as TanstackAppTable } from './components/TanstackAppTable.svelte';
export {
formatTimestampSecondsAsLocal,
timestampSecondsToUTCTimestamp,
promiseTimeout
} from './utils/time';
export { default as Refresh } from './components/icon/Refresh.svelte';
export {
createResolvableQuery,
createResolvableInfiniteQuery,
createResolvableMockQuery
} from './mocks/queries';
Loading

0 comments on commit a3c840f

Please sign in to comment.