Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
1 parent
ab9bbea
commit 15dbe75
Showing
6 changed files
with
385 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
<script lang="ts"> | ||
export let object: any; | ||
</script> | ||
|
||
{object.age} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
<script lang="ts"> | ||
export let object: any; | ||
</script> | ||
|
||
{object.name} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} |