Skip to content

Commit

Permalink
feat: useInputTable
Browse files Browse the repository at this point in the history
Reusable composable to define and render tabular data with selectable rows
  • Loading branch information
patschilf committed Jan 30, 2025
1 parent 265679a commit 1edb8fd
Show file tree
Hide file tree
Showing 5 changed files with 168 additions and 1 deletion.
1 change: 1 addition & 0 deletions lib/composables/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ test('export all composables except useInput', async () => {
expect(index).toHaveProperty('useInputValidation')
expect(index).toHaveProperty('useDataProvider')
expect(index).toHaveProperty('useInputOptionsProvider')
expect(index).toHaveProperty('useInputTable')
})
109 changes: 109 additions & 0 deletions lib/composables/__tests__/useInputTable.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { mount } from "@vue/test-utils"
import { describe, expect, test } from "vitest"
import { defineComponent } from "vue"
import { useInputTable } from "../useInputTable"
import type { InputTableOptions} from "../useInputTable"
import type { Cardinality } from ".."

const TableInput = <C extends Cardinality>(options?: Partial<InputTableOptions<C>>) => defineComponent({
props: {
modelValue: {
type: Object,
required: false,
default: undefined,
},
columns: {
type: Array<string>,
required: true,
},
rows: {
type: Array<object>,
required: true,
},
id: {
type: [String, Function],
required: false,
default: undefined,
},
label: {
type: Object,
required: false,
default: undefined,
}
},
emits: ['update:modelValue', 'select'],
setup(props, { emit }) {
// @ts-expect-error - Ignore type error
return useInputTable<Record<string, string>>(props, emit, options)
},
template: `
<table>
<thead>
<tr>
<th v-for="{id: cid, label} in columns" :key="cid" :data-test="'th-' + cid">{{ label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="[rid, row] in rows.provider.options.value" :key="rid" @click="rows.select(rid)" :data-test="'tr-' + rid">
<td v-for="[column, cell] of Object.entries(row)" :key="rid + ':' + column" :data-test="'td-' + rid + ':' + column">{{ cell }}</td>
</tr>
</tbody>
</table>
`
})

describe("useInputTable", () => {
const columns = ["cid", "uid", "name"]
const rows = [
{ cid: "grey", uid: "gr770", name: "Grey", colour: { hex: "777777" } },
{ cid: "dark-grey", uid: "gr880", name: "Dark Grey", colour: { hex: "0a0a0a" } },
{ cid: "light-grey", uid: "rt56jk", name: "Light grey", colour: { hex: "c0c0c0" } },
]

test("table head", async () => {
const wrapper = mount(TableInput(), { props: { columns, rows } })

expect(wrapper.get('[data-test="th-cid"]').element.textContent).toBe("Cid")
expect(wrapper.get('[data-test="th-uid"]').element.textContent).toBe("Uid")
expect(wrapper.get('[data-test="th-name"]').element.textContent).toBe("Name")
})

test("table data", async () => {
const wrapper = mount(TableInput(), { props: {
columns,
rows,
id: "uid",
label: {
uid: (name: string) => name.toUpperCase(),
colour: "hex",
}
}})

expect(wrapper.get('[data-test="td-gr770:cid"]').element.textContent).toBe("grey")
expect(wrapper.get('[data-test="td-rt56jk:uid"]').element.textContent).toBe("RT56JK")
expect(wrapper.get('[data-test="td-gr880:name"]').element.textContent).toBe("Dark Grey")
})

test("select row", async () => {
const wrapper = mount(TableInput(), { props: { columns, rows }})

await wrapper.get('[data-test="tr-grey"]').trigger('click')

expect(wrapper.emitted()).toHaveProperty("update:modelValue")
expect(wrapper.emitted("update:modelValue")).toHaveLength(1)
expect(wrapper.emitted("update:modelValue")[0]).toEqual(["grey"])
})

test("select multiple rows", async () => {
const wrapper = mount(TableInput({ cardinality: "many" }), { props: { columns, rows }})

await wrapper.get('[data-test="tr-grey"]').trigger('click')
wrapper.setProps({ modelValue: ["grey"] })
await wrapper.get('[data-test="tr-dark-grey"]').trigger('click')

expect(wrapper.emitted()).toHaveProperty("update:modelValue")
expect(wrapper.emitted("update:modelValue")).toHaveLength(2)
expect(wrapper.emitted("update:modelValue")[0]).toEqual([["grey"]])
expect(wrapper.emitted("update:modelValue")[1]).toEqual([["grey", "dark-grey"]])
})
})
2 changes: 2 additions & 0 deletions lib/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export * from "./useInputNumber"
export * from "./useInputValidation"
export * from "./useDataProvider"
export * from "./useInputOptionsProvider"
export * from "./useInputTable"

export type EventHandler = (event: Event) => void
export type Cardinality = "single" | "many"
export type InputHandler<T = unknown> = (handler: (value: T) => void) => EventHandler
2 changes: 1 addition & 1 deletion lib/composables/useInputSelect.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { watch } from "vue"
import useInput from "./useInput"
import { useInputOptionsProvider } from "./useInputOptionsProvider"
import type { Cardinality } from "."
import type { Emit, InputEmits, InputProps, Input, InputOptions } from "./useInput"
import type { InputOptionsProvider, InputOptionsProviderOptions, Option, OptObject } from "./useInputOptionsProvider"

type Cardinality = "single" | "many"
type TData<C extends Cardinality = "single"> = C extends "single" ? string : string[]

export interface InputSelectProps<O extends Option = Option, C extends Cardinality = "single", S = string> extends InputProps<TData<C>> {
Expand Down
55 changes: 55 additions & 0 deletions lib/composables/useInputTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useInputSelect } from "./useInputSelect"
import type { Emit } from "./useInput"
import type { Cardinality } from "."
import type { InputSelectEmits, InputSelectProps } from "./useInputSelect"

export type InputTableProps<T extends Record<string, unknown> = Record<string, unknown>, C extends Cardinality = "single", S = string> = T extends Record<infer L, infer D>
? {
columns: L[]
rows: InputSelectProps<Record<L, D>, C>[`options`]
id?: keyof T | ((row: T) => string)
label?: Record<keyof T, S | ((cell: T[keyof T]) => S)>
}
: InputTableProps<Record<string, string>, C, S>

export type InputTableEmits<T extends Record<string, unknown> = Record<string, unknown>, C extends Cardinality = "single"> = T extends Record<infer L, infer D>
? InputSelectEmits<Record<L, D>, C>
: InputSelectEmits<Record<string, string>, C>

export interface InputTableOptions<C extends Cardinality = "single"> {
cardinality: C
}

export type InputTable<T extends Record<string, unknown> = Record<string, unknown>, C extends Cardinality = "single", S = T> = T extends Record<infer L, infer D>
? {
columns: {
id: L
label: string
}[]
rows: ReturnType<typeof useInputSelect<Record<L, D>, C, S>>
}
: InputTable<Record<string, string>, C>

export function useInputTable<T extends Record<string, unknown> = Record<string, unknown>, C extends Cardinality = "single", S = string>(props: InputTableProps<T, C>, emits: Emit<InputTableEmits<T, C>>, options?: Partial<InputTableOptions<C>>): InputTable<T, C, S> {
return {
columns: props.columns.map((column) => ({
id: column,
label: column.at(0).toUpperCase() + column.slice(1).toLowerCase(),
})),
rows: useInputSelect<T, C, S>({
options: props.rows as T[],
id: (row) => typeof props.id === "function"
? props.id(row) : props.id && row[props.id]
? `${row[props.id]}` : `${row[Object.keys(row)[0]]}`,
label: (row) => Object.fromEntries(
Object.entries(row)
.map(([column, cell]) => [
column,
typeof props.label?.[column] === "function"
? props.label[column](cell)
: typeof props.label?.[column] === "string" && cell[props.label?.[column]]
? cell[props.label?.[column]] : cell
])) as S
}, emits, { cardinality: "single" as C, ...options }),
} as InputTable<T, C, S>
}

0 comments on commit 1edb8fd

Please # to comment.