|
5 | 5 | :rows="mappedRows" |
6 | 6 | :columns="mappedColumns" |
7 | 7 | row-key="id" |
| 8 | + v-model:expanded="expanded" |
8 | 9 | :filter="filterModel" |
9 | 10 | :filter-method="customFilterMethod" |
10 | 11 | virtual-scroll |
|
16 | 17 | :pagination="{ rowsPerPage: 0 }" |
17 | 18 | hide-bottom |
18 | 19 | > |
19 | | - <template v-slot:top v-if="searchInputVisible"> |
| 20 | + <!-- search field -------------------------------------------------------> |
| 21 | + <template #top v-if="searchInputVisible"> |
20 | 22 | <div class="row full-width items-center q-mb-sm"> |
21 | 23 | <div class="col"> |
22 | 24 | <q-input |
|
28 | 30 | class="search-field white-outline-input" |
29 | 31 | input-class="text-white" |
30 | 32 | > |
31 | | - <template v-slot:append> |
| 33 | + <template #append> |
32 | 34 | <q-icon name="search" color="white" /> |
33 | 35 | </template> |
34 | 36 | </q-input> |
35 | 37 | </div> |
36 | 38 | </div> |
37 | 39 | </template> |
38 | 40 |
|
39 | | - <!-- Dynamic slot for custom cell rendering --> |
| 41 | + <!-- header -----------------------------------------------------------> |
| 42 | + <template v-if="props.rowExpandable" #header="header"> |
| 43 | + <q-tr :props="header"> |
| 44 | + <!-- space for arrow column --> |
| 45 | + <q-th auto-width :props="{ ...header, col: {} }" /> |
| 46 | + <!-- the other columns --> |
| 47 | + <q-th |
| 48 | + v-for="column in header.cols" |
| 49 | + :key="column.name" |
| 50 | + :props="{ ...header, col: column }" |
| 51 | + > |
| 52 | + {{ column.label }} |
| 53 | + </q-th> |
| 54 | + </q-tr> |
| 55 | + </template> |
| 56 | + |
| 57 | + <!-- body -------------------------------------------------------------> |
| 58 | + <template v-if="props.rowExpandable" #body="rowProps: BodySlotProps<T>"> |
| 59 | + <q-tr |
| 60 | + :key="`main-${rowProps.key}`" |
| 61 | + :props="rowProps" |
| 62 | + @click="onRowClick($event, rowProps.row)" |
| 63 | + class="clickable" |
| 64 | + > |
| 65 | + <q-td auto-width> |
| 66 | + <q-btn |
| 67 | + dense |
| 68 | + flat |
| 69 | + round |
| 70 | + size="sm" |
| 71 | + :icon=" |
| 72 | + rowProps.expand ? 'keyboard_arrow_up' : 'keyboard_arrow_down' |
| 73 | + " |
| 74 | + @click.stop="rowProps.expand = !rowProps.expand" |
| 75 | + /> |
| 76 | + </q-td> |
| 77 | + |
| 78 | + <template v-for="column in rowProps.cols" :key="column.name"> |
| 79 | + <!-- custom body-cell slot --> |
| 80 | + <template v-if="$slots[`body-cell-${column.name}`]"> |
| 81 | + <slot |
| 82 | + :name="`body-cell-${column.name}`" |
| 83 | + v-bind="{ |
| 84 | + ...rowProps, |
| 85 | + col: column, |
| 86 | + }" |
| 87 | + > |
| 88 | + </slot> |
| 89 | + </template> |
| 90 | + |
| 91 | + <!-- all other column data --> |
| 92 | + <q-td |
| 93 | + v-else |
| 94 | + :props="{ |
| 95 | + ...rowProps, |
| 96 | + col: column, |
| 97 | + // cast necessary as field comes from q-table and is defined: field: string | ((row: any) => any); |
| 98 | + value: rowProps.row[column.field as string], |
| 99 | + }" |
| 100 | + > |
| 101 | + <!-- cast necessary as field comes from q-table and is defined: field: string | ((row: any) => any); --> |
| 102 | + {{ rowProps.row[column.field as string] }} |
| 103 | + </q-td> |
| 104 | + </template> |
| 105 | + </q-tr> |
| 106 | + |
| 107 | + <!-- expansion row --> |
| 108 | + <q-tr |
| 109 | + v-show="rowProps.expand" |
| 110 | + :key="`xp-${rowProps.key}`" |
| 111 | + :props="rowProps" |
| 112 | + class="q-virtual-scroll--with-prev" |
| 113 | + > |
| 114 | + <q-td :colspan="rowProps.cols.length + 1"> |
| 115 | + <slot name="row-expand" v-bind="rowProps"> </slot> |
| 116 | + </q-td> |
| 117 | + </q-tr> |
| 118 | + </template> |
| 119 | + |
| 120 | + <!-- forward any other slots not related to table e.g top search field --------------------> |
40 | 121 | <template |
41 | | - v-for="(_, name) in $slots" |
42 | | - :key="name" |
43 | | - v-slot:[name]="slotData" |
| 122 | + v-for="slotName in forwardedSlotNames" |
| 123 | + :key="slotName" |
| 124 | + v-slot:[slotName]="slotProps" |
44 | 125 | > |
45 | | - <slot :name="name" v-bind="slotData"></slot> |
| 126 | + <slot :name="slotName" v-bind="slotProps"></slot> |
46 | 127 | </template> |
47 | 128 | </q-table> |
48 | 129 | </div> |
49 | 130 | </template> |
50 | 131 |
|
51 | | -<script setup lang="ts"> |
52 | | -import { computed, ComputedRef } from 'vue'; |
53 | | -import { QTableColumn, QTableProps } from 'quasar'; |
| 132 | +<script setup lang="ts" generic="T extends Record<string, unknown>"> |
| 133 | +import { computed, ComputedRef, ref, useSlots } from 'vue'; |
| 134 | +import type { QTableColumn, QTableProps } from 'quasar'; |
| 135 | +import { |
| 136 | + ColumnConfiguration, |
| 137 | + BodySlotProps, |
| 138 | +} from 'src/components/models/table-model'; |
54 | 139 |
|
| 140 | +/* ------------------------------------------------------------------ props */ |
55 | 141 | const props = defineProps<{ |
56 | 142 | items: number[]; |
57 | | - rowData: |
58 | | - | ((item: number) => Record<string, unknown>) |
59 | | - | ComputedRef<(item: number) => Record<string, unknown>>; |
60 | | - columnConfig: { |
61 | | - fields: string[]; |
62 | | - labels?: Record<string, string>; |
63 | | - }; |
| 143 | + rowData: ((item: number) => T) | ComputedRef<(item: number) => T>; |
| 144 | + columnConfig: ColumnConfiguration[]; |
64 | 145 | rowKey?: string; |
65 | 146 | searchInputVisible?: boolean; |
66 | 147 | tableHeight?: string; |
67 | 148 | filter?: string; |
68 | 149 | columnsToSearch?: string[]; |
| 150 | + rowExpandable?: boolean; |
69 | 151 | }>(); |
70 | 152 |
|
| 153 | +/* ------------------------------------------------------------------ state */ |
| 154 | +const expanded = ref<(string | number)[]>([]); |
| 155 | +const slots = useSlots(); |
| 156 | +
|
| 157 | +const forwardedSlotNames = computed(() => { |
| 158 | + if (props.rowExpandable) |
| 159 | + return Object.keys(slots).filter((name) => !name.startsWith('body')); |
| 160 | + return Object.keys(slots); |
| 161 | +}); |
| 162 | +
|
71 | 163 | const emit = defineEmits<{ |
72 | | - (e: 'row-click', row: Record<string, unknown>): void; |
73 | | - (e: 'update:filter', value: string): void; |
| 164 | + (event: 'row-click', row: T): void; |
| 165 | + (event: 'update:filter', value: string): void; |
74 | 166 | }>(); |
75 | 167 |
|
| 168 | +/* ---------------------------------------------------------------- helpers */ |
76 | 169 | const filterModel = computed({ |
77 | 170 | get: () => props.filter || '', |
78 | 171 | set: (value) => emit('update:filter', value), |
79 | 172 | }); |
80 | 173 |
|
81 | | -// Data can be passed to basetable as a normal function or computed property |
82 | | -const rowMapperFn = computed(() => |
83 | | - typeof props.rowData === 'function' ? props.rowData : props.rowData.value, |
| 174 | +const mappedRows = computed(() => |
| 175 | + props.items.map( |
| 176 | + typeof props.rowData === 'function' ? props.rowData : props.rowData.value, |
| 177 | + ), |
84 | 178 | ); |
85 | 179 |
|
86 | | -const mappedRows = computed(() => props.items.map(rowMapperFn.value)); |
87 | | -
|
88 | | -const mappedColumns = computed<QTableColumn[]>(() => { |
89 | | - return props.columnConfig.fields.map((field) => ({ |
90 | | - name: field, |
91 | | - label: props.columnConfig.labels?.[field] || field, |
92 | | - field, |
93 | | - align: 'left', |
94 | | - sortable: true, |
95 | | - headerStyle: 'font-weight: bold', |
96 | | - })); |
97 | | -}); |
| 180 | +const mappedColumns = computed<QTableColumn[]>(() => |
| 181 | + props.columnConfig |
| 182 | + .filter((column) => !column.expandField) // main table columns only |
| 183 | + .map((column) => ({ |
| 184 | + name: column.field, |
| 185 | + field: column.field, |
| 186 | + label: column.label, |
| 187 | + align: column.align ?? 'left', |
| 188 | + sortable: true, |
| 189 | + headerStyle: 'font-weight: bold', |
| 190 | + })), |
| 191 | +); |
98 | 192 |
|
99 | 193 | const customFilterMethod: NonNullable<QTableProps['filterMethod']> = ( |
100 | 194 | rows, |
101 | | - terms, |
102 | | - cols, |
| 195 | + searchTerms, |
| 196 | + columns, |
103 | 197 | ) => { |
104 | | - if (!terms || terms.trim() === '') return rows; |
105 | | - const lowerTerms = terms.toLowerCase(); |
| 198 | + if (!searchTerms || searchTerms.trim() === '') return rows; |
| 199 | + const lowerTerms = searchTerms.toLowerCase(); |
106 | 200 | const fields = |
107 | 201 | props.columnsToSearch || |
108 | | - cols.map((col) => (typeof col.field === 'string' ? col.field : '')); |
| 202 | + columns.map((column) => |
| 203 | + typeof column.field === 'string' ? column.field : '', |
| 204 | + ); |
109 | 205 | return rows.filter((row) => |
110 | 206 | fields.some((field) => { |
111 | | - const val = row[field]; |
112 | | - return val && String(val).toLowerCase().includes(lowerTerms); |
| 207 | + const value = row[field]; |
| 208 | + return value && String(value).toLowerCase().includes(lowerTerms); |
113 | 209 | }), |
114 | 210 | ); |
115 | 211 | }; |
116 | 212 |
|
117 | | -const onRowClick = (evt: Event, row: Record<string, unknown>) => |
118 | | - emit('row-click', row); |
| 213 | +const onRowClick = (evt: Event, row: T) => emit('row-click', row); |
119 | 214 | </script> |
120 | 215 |
|
121 | 216 | <style scoped> |
122 | 217 | .search-field { |
123 | 218 | width: 100%; |
124 | 219 | max-width: 18em; |
125 | 220 | } |
| 221 | +
|
| 222 | +.clickable { |
| 223 | + cursor: pointer; |
| 224 | +} |
126 | 225 | </style> |
0 commit comments