Skip to content

Commit

Permalink
feat: table component
Browse files Browse the repository at this point in the history
This is a clean branch of the table component after previous review comments
in PR #4545.

Each page passes an array of objects and Columns to the Table component, which
is responsible for creating the basic layout and calling back to the Column
renderer to render each cell in the table. The Table uses grid layout and
provides enough abstraction that we can implement additional features later.
(e.g. optional columns, column reordering, or resizing. grouping. etc)

The Table provides sorting & sort indicators, and tests are included.
Support for 'object containers/grouping' in order to support Container list
will be done separately since this is already a big PR.

Fixes #4365.

Signed-off-by: Tim deBoer <git@tdeboer.ca>
  • Loading branch information
deboer-tim committed Nov 16, 2023
1 parent ab9bbea commit 15dbe75
Show file tree
Hide file tree
Showing 6 changed files with 385 additions and 0 deletions.
87 changes: 87 additions & 0 deletions packages/renderer/src/lib/table/Table.spec.ts
@@ -0,0 +1,87 @@
/**********************************************************************
* Copyright (C) 2023 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import '@testing-library/jest-dom/vitest';
import { test, expect } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/svelte';

import TestTable from './TestTable.svelte';

test('Expect basic table layout', async () => {
// render the component
render(TestTable, {});

// 3 people = header + 3 rows
const rows = await screen.findAllByRole('row');
expect(rows).toBeDefined();
expect(rows.length).toBe(4);

// first data row should contain John and his age
expect(rows[1].textContent).toContain('John');
expect(rows[1].textContent).toContain('57');

// second data row should contain Henry and his age
expect(rows[2].textContent).toContain('Henry');
expect(rows[2].textContent).toContain('27');

// last data row should contain Charlie and his age
expect(rows[3].textContent).toContain('Charlie');
expect(rows[3].textContent).toContain('43');
});

test('Expect sorting by name works', async () => {
render(TestTable, {});

const nameCol = screen.getByText('Name');
expect(nameCol).toBeInTheDocument();

let rows = await screen.findAllByRole('row');
expect(rows).toBeDefined();
expect(rows.length).toBe(4);
expect(rows[1].textContent).toContain('John');
expect(rows[2].textContent).toContain('Henry');
expect(rows[3].textContent).toContain('Charlie');

await fireEvent.click(nameCol);

rows = await screen.findAllByRole('row');
expect(rows[1].textContent).toContain('Charlie');
expect(rows[2].textContent).toContain('Henry');
expect(rows[3].textContent).toContain('John');
});

test('Expect sorting by age works', async () => {
render(TestTable, {});

const ageCol = screen.getByText('Age');
expect(ageCol).toBeInTheDocument();

let rows = await screen.findAllByRole('row');
expect(rows).toBeDefined();
expect(rows.length).toBe(4);
expect(rows[1].textContent).toContain('John');
expect(rows[2].textContent).toContain('Henry');
expect(rows[3].textContent).toContain('Charlie');

await fireEvent.click(ageCol);

rows = await screen.findAllByRole('row');
expect(rows[1].textContent).toContain('Henry');
expect(rows[2].textContent).toContain('Charlie');
expect(rows[3].textContent).toContain('John');
});
165 changes: 165 additions & 0 deletions packages/renderer/src/lib/table/Table.svelte
@@ -0,0 +1,165 @@
<style>
.grid-table {
display: grid;
}
</style>

<script lang="ts">
/* eslint-disable import/no-duplicates */
// https://github.com/import-js/eslint-plugin-import/issues/1479
import { afterUpdate, tick } from 'svelte';
import Checkbox from '../ui/Checkbox.svelte';
import type { Column, Row } from './table';
import { flip } from 'svelte/animate';
/* eslint-enable import/no-duplicates */
export let kind: string;
export let data: any[];
export let columns: Column<any>[];
export let row: Row<any>;
// number of selected items in the list
export let selectedItemsNumber: number = 0;
$: selectedItemsNumber = row.selectable
? data.filter(object => row.selectable?.(object)).filter(object => object.selected).length
: 0;
// do we need to unselect all checkboxes if we don't have all items being selected ?
$: selectedAllCheckboxes = row.selectable
? data.filter(object => row.selectable?.(object)).every(object => object.selected)
: false;
function toggleAll(checked: boolean) {
if (!row.selectable) {
return;
}
const toggleData = data;
toggleData.filter(object => row.selectable?.(object)).forEach(object => (object.selected = checked));
data = toggleData;
}
let sortCol: Column<any>;
let sortAscending: boolean;
function sort(column: Column<any>) {
if (!column) {
return;
}
let comparator = column.comparator;
if (!comparator) {
// column is not sortable
return;
}
if (sortCol === column) {
sortAscending = !sortAscending;
} else {
sortCol = column;
sortAscending = true;
}
if (!sortAscending) {
// we're already sorted, switch to reverse order
let comparatorTemp = comparator;
comparator = (a, b) => -comparatorTemp(a, b);
}
const sortedData = data;
sortedData.sort(comparator);
data = sortedData;
}
afterUpdate(async () => {
await tick();
setGridColumns();
});
function setGridColumns() {
// section and checkbox columns
let columnWidths: string[] = ['20px', '32px'];
// custom columns
for (const column of columns) {
if (column.info.width) {
columnWidths.push(column.info.width);
} else {
columnWidths.push('1fr');
}
}
columnWidths.push('5px');
let wid = columnWidths.join(' ');
let grids: HTMLCollection = document.getElementsByClassName('grid-table');
for (const element of grids) {
(element as HTMLElement).style.setProperty('grid-template-columns', wid);
}
}
</script>

<div class="w-full" class:hidden="{data.length === 0}" role="table">
<!-- Table header -->
<div
class="grid grid-table gap-x-0.5 mx-5 h-7 sticky top-0 bg-charcoal-700 text-xs text-gray-600 font-bold uppercase z-[2]"
role="row">
<div class="whitespace-nowrap justify-self-start"></div>
<div class="whitespace-nowrap place-self-center" role="columnheader">
{#if row.selectable}
<Checkbox
title="Toggle all"
bind:checked="{selectedAllCheckboxes}"
indeterminate="{selectedItemsNumber > 0 && !selectedAllCheckboxes}"
on:click="{event => toggleAll(event.detail)}" />
{/if}
</div>
{#each columns as column}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-interactive-supports-focus -->
<div
class="whitespace-nowrap {column.info.align === 'right'
? 'justify-self-end'
: column.info.align === 'center'
? 'justify-self-center'
: 'justify-self-start'} self-center"
on:click="{() => sort(column)}"
role="columnheader">
{column.title}{#if column.comparator}<i
class="fas pl-0.5"
class:fa-sort="{sortCol !== column}"
class:fa-sort-up="{sortCol === column && !sortAscending}"
class:fa-sort-down="{sortCol === column && sortAscending}"
aria-hidden="true"></i
>{/if}
</div>
{/each}
</div>
<!-- Table body -->
{#each data as object (object)}
<div
class="grid grid-table gap-x-0.5 mx-5 h-12 bg-charcoal-800 hover:bg-zinc-700 rounded-lg mb-2"
animate:flip="{{ duration: 300 }}"
role="row">
<div class="whitespace-nowrap justify-self-start"></div>
<div class="whitespace-nowrap place-self-center">
{#if row.selectable}
<Checkbox
title="Toggle {kind}"
bind:checked="{object.selected}"
disabled="{!row.selectable(object)}"
disabledTooltip="{row.disabledText}}" />
{/if}
</div>
{#each columns as column}
<div
class="whitespace-nowrap {column.info.align === 'right'
? 'justify-self-end'
: column.info.align === 'center'
? 'justify-self-center'
: 'justify-self-start'} self-center overflow-hidden"
role="cell">
{#if column.info.renderer}
<svelte:component this="{column.info.renderer}" object="{object}" />
{/if}
</div>
{/each}
</div>
{/each}
</div>
5 changes: 5 additions & 0 deletions packages/renderer/src/lib/table/TestColumnAge.svelte
@@ -0,0 +1,5 @@
<script lang="ts">
export let object: any;
</script>

{object.age}
5 changes: 5 additions & 0 deletions packages/renderer/src/lib/table/TestColumnName.svelte
@@ -0,0 +1,5 @@
<script lang="ts">
export let object: any;
</script>

{object.name}
40 changes: 40 additions & 0 deletions packages/renderer/src/lib/table/TestTable.svelte
@@ -0,0 +1,40 @@
<script lang="ts">
import Table from './Table.svelte';
import TestColumnName from './TestColumnName.svelte';
import TestColumnAge from './TestColumnAge.svelte';
import { Column, Row } from './table';
let table: Table;
let selectedItemsNumber: number;
type Person = {
name: string;
age: number;
};
const people: Person[] = [
{ name: 'John', age: 57 },
{ name: 'Henry', age: 27 },
{ name: 'Charlie', age: 43 },
];
const nameCol: Column<Person> = new Column('Name', { width: '3fr', renderer: TestColumnName });
nameCol.setComparator((a, b) => a.name.localeCompare(b.name));
const ageCol: Column<Person> = new Column('Age', { align: 'right', renderer: TestColumnAge });
ageCol.setComparator((a, b) => a.age - b.age);
const columns: Column<any>[] = [nameCol, ageCol];
const row = new Row<Person>();
row.setSelectable(person => person.age < 50, 'People over 50 cannot be deleted');
</script>

<Table
kind="people"
bind:this="{table}"
bind:selectedItemsNumber="{selectedItemsNumber}"
data="{people}"
columns="{columns}"
row="{row}">
</Table>
83 changes: 83 additions & 0 deletions packages/renderer/src/lib/table/table.ts
@@ -0,0 +1,83 @@
/**********************************************************************
* Copyright (C) 2023 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

/**
* Options to be used when creating a Column.
*/
export interface ColumnInformation {
/**
* Column alignment, one of 'left', 'center', or 'right'.
*
* Defaults to 'left' alignment.
*/
readonly align?: 'left' | 'center' | 'right';

/**
* Column width, typically in pixels or fractional units (fr).
*
* Defaults to '1fr'.
*/
readonly width?: string;

/**
* Svelte component, renderer for each cell in the column.
* The component must have a property 'object' that has the
* same type as the Column.
*/
readonly renderer?: any;
}

/**
* A table Column.
*/
export class Column<Type> {
comparator: ((object1: Type, object2: Type) => number) | undefined;

constructor(
readonly title: string,
readonly info: ColumnInformation,
) {}

/**
* Set a comparator used to sort the data by the values in this column.
*
* @param comparator
*/
setComparator(comparator: (object1: Type, object2: Type) => number) {
this.comparator = comparator;
}
}

/**
* A table row.
*/
export class Row<Type> {
selectable?: (object: Type) => boolean;
disabledText?: string;

/**
* Set a function to be used to determine which objects in the data can be selected.
*
* @param selectable a function that returns false when the object should not be selectable
* @param disabledText text to display as a tooltip when selection is disabled
*/
setSelectable(selectable: (object: Type) => boolean, disabledText: string) {
this.selectable = selectable;
this.disabledText = disabledText;
}
}

0 comments on commit 15dbe75

Please sign in to comment.