Skip to content

Commit

Permalink
feat(web-core): new useTable composable
Browse files Browse the repository at this point in the history
  • Loading branch information
ByScripts committed Sep 10, 2024
1 parent 7c31b44 commit 7e4240d
Show file tree
Hide file tree
Showing 10 changed files with 517 additions and 0 deletions.
159 changes: 159 additions & 0 deletions @xen-orchestra/web-core/docs/composables/table.composable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# `useTable` composable

## Usage

```ts
const { columns, visibleColumns, rows, columnsById } = useTable('<table-id>', records, {
rowId: record => record.id,
columns: define => [
define('<column-id>', { label: 'Column 1' }),
define('<column-id>', { label: 'Column 2' }),
define('<column-id>', { label: 'Column 3' }),
],
})
```

## `useTable` options

| Name | Type | Required | Description |
| --------- | ---------------------------------------------- | :------: | ------------------------------------------------- |
| `rowId` | `(record: TRecord) => string` || A function that define the id of a row. |
| `columns` | `(define: DefineColumn) => ColumnDefinition[]` || A function that defines the columns of the table. |

## Defining a column

```ts
define('<TColumnId>', options) // TValue will be TRecord[TColumnId]
define('<TColumnId>', `<TProperty>`, options) // TValue will be TRecord[TProperty]
define('<TColumnId>', (record: TRecord) => '<TValue>', options) // TValue will be the result of the function
```

### Column options

| Name | Type | Required | Default | Description |
| ------------ | ---------------------------------- | :------: | ------- | -------------------------------------- |
| `label` | `string` || | The column label. |
| `isHideable` | `boolean` | | `true` | Indicates if the column can be hidden. |
| `compareFn` | `(a: TValue, b: TValue) => number` | | | A function used to compare the values. |

## `columns`

An array containing all columns defined in the table.

### Properties of a column

| Name | Type | Description |
| ------------ | ----------------------------- | ------------------------------------------------ |
| `id` | `string` | The column id. |
| `label` | `string` | The column label. |
| `isVisible` | `boolean` | Indicates if the column is visible. |
| `getter` | `(record: TRecord) => TValue` | A function that returns the value of the column. |
| `isSortable` | `boolean` | Indicates if the column is sortable. |
| `isHideable` | `boolean` | Indicates if the column is hideable. |

#### If `isSortable` is `true`

| Name | Type | Description |
| --------------------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------- |
| `compareFn` | `(a: TValue, b: TValue) => number` | The compare function defined in the column options. |
| `isSorted` | `boolean` | Indicates if the column is sorted. |
| `isSortedAsc` | `boolean` | Indicates if the column is sorted in ascending order. |
| `isSortedDesc` | `boolean` | Indicates if the column is sorted in descending order. |
| `sort` | `(direction, toggleOffIfSameDirection?) => void` | A function that sorts the rows based on the column values. |
| &nbsp;&nbsp;`direction` | `'asc' \| 'desc' \| false` | The sort direction. If `false`, the column is unsorted. |
| &nbsp;&nbsp;`toggleOffIfSameDirection` | `boolean` | Indicates if the column should be unsorted if it is already sorted in the same direction. |
| `sortAsc` | `(toggleOffIfSameDirection?) => void` | A function that sorts the rows based on the column values in ascending order. |
| &nbsp;&nbsp;`toggleOffIfSameDirection` | `boolean` | Indicates if the column should be unsorted if it is already sorted in ascending order. |
| `sortDesc` | `(toggleOffIfSameDirection?) => void` | A function that sorts the rows based on the column values in descending order. |
| &nbsp;&nbsp;`toggleOffIfSameDirection` | `boolean` | Indicates if the column should be unsorted if it is already sorted in descending order. |

#### If `isHideable` is `true`

| Name | Type | Description |
| -------------------- | --------------------------- | ------------------------------------------------------------------------------- |
| `hide` | `() => void` | A function that hides the column. |
| `show` | `() => void` | A function that shows the column. |
| `toggle` | `(value?: boolean) => void` | A function that toggles the visibility of the column. |
| &nbsp;&nbsp;`value` | `boolean \| undefined` | If undefined, the visibility will be toggled. Else it will be set to the value. |

## `visibleColumns`

Same as `columns` but only contains the visible columns.

## `rows`

An array containing all rows of the table.

### Properties of a row

| Name | Type | Description |
| ---------------- | ---------- | ------------------------------- |
| `id` | `string` | The row id. |
| `value` | `TRecord` | The record of the row. |
| `visibleColumns` | `Column[]` | The visible columns of the row. |

#### `visibleColumns`

An array containing the visible columns of the row.

##### Properties of a row column

| Name | Type | Description |
| ------- | -------- | ------------------------ |
| `id` | `string` | The column id. |
| `value` | `TValue` | The value of the column. |

## `columnsById`

An object containing all columns defined in the table indexed by their id.

## Example

```vue
<template>
<div>
<button v-for="column of columns" :key="column.id" @click.prevent="column.toggle()">
{{ column.isVisible ? 'Hide' : 'Show' }} {{ column.label }}
</button>
</div>
<table>
<thead>
<tr>
<th v-for="column of visibleColumns" :key="column.id">
{{ column.label }}
<button v-if="column.isHideable" @click.prevent="column.hide()">Hide</button>
<template v-if="column.isSortable">
<button @click.prevent="column.sortAsc(true)">Sort ASC</button>
<button @click.prevent="column.sortDesc(true)">Sort DESC</button>
</template>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row of rows" :key="row.id">
<td v-for="column of row.visibleColumns" :key="column.id">
{{ column.value }}
</td>
</tr>
</tbody>
</table>
</template>
<script lang="ts" setup>
const { columns, visibleColumns, rows } = useTable(
'users',
[
{ id: 1, name: 'John', age: 25 },
{ id: 2, name: 'Jane', age: 30 },
{ id: 3, name: 'Alice', age: 20 },
],
{
rowId: record => record.id,
columns: define => [
define('name', { label: 'Name', isHideable: false }),
define('age', { label: 'Age', compareFn: (user1, user2) => user1.age - user2.age }),
],
}
)
</script>
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useRouteQuery } from '@core/composables/route-query.composable'

export function useHideRouteQuery(id: string) {
return useRouteQuery(id, {
toData: query => new Set(query ? query.split(',') : undefined),
toQuery: data => Array.from(data).join(','),
})
}

export type HideRouteQuery = ReturnType<typeof useHideRouteQuery>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useRouteQuery } from '@core/composables/route-query.composable'

export function useSortRouteQuery(id: string) {
return useRouteQuery(id, {
toData: query => {
if (!query) {
return undefined
}

const [id, direction] = query.split(',') as [string, 'asc' | 'desc']

return { id, direction }
},
toQuery: data => (data ? [data.id, data.direction].join(',') : ''),
})
}

export type SortRouteQuery = ReturnType<typeof useSortRouteQuery>
78 changes: 78 additions & 0 deletions @xen-orchestra/web-core/lib/composables/table.composable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useHideRouteQuery } from '@core/composables/hide-route-query.composable'
import { useSortRouteQuery } from '@core/composables/sort-route-query.composable'
import { createDefineColumn } from '@core/composables/table/create-define-column'
import type { ColumnDefinition, Table, TableOptions } from '@core/composables/table/type'
import type { MaybeRefOrGetter } from 'vue'
import { computed, reactive, toValue } from 'vue'

export function useTable<TRecord, TRowId, const TDefinitions extends ColumnDefinition<any, TRecord, any, any, any>>(
id: string,
records: MaybeRefOrGetter<TRecord[]>,
options: TableOptions<TRecord, TRowId, TDefinitions>
): Table<TRowId, TDefinitions[]> {
const hideRouteQuery = useHideRouteQuery(`table.${id}.hide`)

const sortRouteQuery = useSortRouteQuery(`table.${id}.sort`)

const defineColumn = createDefineColumn<TRecord>(hideRouteQuery, sortRouteQuery)

const columns = options.columns(defineColumn)

const columnsById = Object.fromEntries(columns.map(column => [column.id, column])) as Record<
string,
ColumnDefinition<any, TRecord, any, any, any>
>

const visibleColumns = computed(() => columns.filter(column => column.isVisible))

const rows = computed(() =>
toValue(records).map(record => {
const rowId = options.rowId(record)

const visibleRowColumns = computed(() => {
return visibleColumns.value.map(column => {
return {
id: column.id,
value: column.getter(record),
}
})
})

return reactive({
id: rowId,
value: record,
visibleColumns: visibleRowColumns,
})
})
)

const sortedRows = computed(() => {
const sort = sortRouteQuery.value

if (sort === undefined) {
return rows.value
}

const sortColumn = columnsById[sort.id]

if (sortColumn === undefined || !sortColumn.isSortable) {
return rows.value
}

const compareFn = sortColumn.compareFn

return rows.value.slice().sort((row1, row2) => {
const value1 = sortColumn.getter(row1.value as TRecord)
const value2 = sortColumn.getter(row2.value as TRecord)

return sort.direction === 'asc' ? compareFn(value1, value2) : compareFn(value2, value1)
})
})

return {
columns: computed(() => columns),
columnsById: computed(() => columnsById),
visibleColumns,
rows: sortedRows,
} as Table<TRowId, TDefinitions[]>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { BaseDefinition, ColumnOptions } from '@core/composables/table/type'

export function createBaseDefinition<TId extends string, TRecord>(
columnId: TId,
optionsOrGetter: any,
options: ColumnOptions<any, any, any>
): BaseDefinition<TId, TRecord, any> {
const getter =
typeof optionsOrGetter === 'function'
? optionsOrGetter
: typeof optionsOrGetter === 'string'
? (item: TRecord) => item[optionsOrGetter as keyof TRecord]
: (item: TRecord) => item[columnId as unknown as keyof TRecord]

return {
id: columnId,
label: options.label,
getter,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { HideRouteQuery } from '@core/composables/hide-route-query.composable'
import type { SortRouteQuery } from '@core/composables/sort-route-query.composable'
import { createBaseDefinition } from '@core/composables/table/create-base-definition'
import { createSortingDefinition } from '@core/composables/table/create-sorting-definition'
import { createVisibilityDefinition } from '@core/composables/table/create-visibility-definition'
import type { ColumnDefinition, ColumnOptions, DefineColumn } from '@core/composables/table/type'
import { reactive } from 'vue'

export function createDefineColumn<TRecord>(
hideRouteQuery: HideRouteQuery,
sortRouteQuery: SortRouteQuery
): DefineColumn<TRecord> {
return function defineColumn<TId extends string>(
columnId: TId,
optionsOrGetter: any,
optionsOrNone?: any
): ColumnDefinition<TId, TRecord, any, any, any> {
const options = (optionsOrNone ?? optionsOrGetter) as ColumnOptions<any, any, any>

return reactive({
...createBaseDefinition(columnId, optionsOrGetter, options),
...createVisibilityDefinition(columnId, hideRouteQuery, options.isHideable),
...createSortingDefinition(columnId, sortRouteQuery, options.compareFn),
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { SortRouteQuery } from '@core/composables/sort-route-query.composable'
import type { CompareFn, SortingDefinition } from '@core/composables/table/type'
import { computed } from 'vue'

export function createSortingDefinition<TCompareReturn extends number | unknown>(
columnId: string,
sortRouteQuery: SortRouteQuery,
compareFn: CompareFn<any, TCompareReturn> | undefined
): SortingDefinition<any, TCompareReturn> {
if (compareFn === undefined) {
return {
isSortable: false,
} as SortingDefinition<any, TCompareReturn>
}

const isSorted = computed(() => sortRouteQuery.value?.id === columnId)

const isSortedAsc = computed(() => isSorted.value && sortRouteQuery.value?.direction === 'asc')

const isSortedDesc = computed(() => isSorted.value && sortRouteQuery.value?.direction === 'desc')

function sort(direction: 'asc' | 'desc' | false, toggleOffIfSameDirection = false) {
const shouldToggleOff =
direction === false ||
(toggleOffIfSameDirection && isSorted.value && sortRouteQuery.value?.direction === direction)

sortRouteQuery.value = shouldToggleOff ? undefined : { id: columnId, direction }
}

function sortAsc(toggleOffIfSameDirection = false) {
sort('asc', toggleOffIfSameDirection)
}

function sortDesc(toggleOffIfSameDirection = false) {
sort('desc', toggleOffIfSameDirection)
}

return {
isSortable: true,
isSorted,
isSortedAsc,
isSortedDesc,
sort,
sortAsc,
sortDesc,
compareFn,
} as SortingDefinition<any, TCompareReturn>
}
Loading

0 comments on commit 7e4240d

Please sign in to comment.