PluginProbe
The Innovative Form Builder – IvyForms / 1.1
The Innovative Form Builder – IvyForms v1.1
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / frontend / src / views / _components / table / IvyTable.vue

IvyTable.vue in The Innovative Form Builder – IvyForms 1.1, at frontend/src/views/_components/table/IvyTable.vue

413 lines 11.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <template>
2 <div
3 ref="tableContainerRef"
4 class="ivyforms-table__container ivyforms-flex ivyforms-flex-direction-column ivyforms-width-100"
5 >
6 <!-- Slot for custom header (when row is selected) -->
7 <div v-if="!!$slots['bulk-header']" class="ivyforms-table__custom-header ivyforms-pt-2">
8 <slot name="bulk-header" />
9 </div>
10 <IvyScrollbar ref="scrollbarRef">
11 <ElAutoResizer :class="{ loading: props.loading }">
12 <template #default="{ width }">
13 <ElTableV2
14 v-bind="$attrs"
15 :columns="formattedColumns"
16 :data="props.data"
17 :width="Math.max(width, props.minTableWidth)"
18 :height="tableHeight"
19 class="ivyforms-table"
20 :fixed="props.fixed"
21 :row-class="getRowClass"
22 >
23 <!-- Slot for custom column headers with sorting -->
24 <template #header-cell="{ column }">
25 <div
26 class="ivyforms-flex ivyforms-align-items-center"
27 :style="{
28 cursor: column.sortable ? 'pointer' : 'default',
29 }"
30 @click="emitSortChange(column)"
31 >
32 <component :is="column.headerRenderer()" v-if="column.headerRenderer" />
33
34 <div
35 v-else-if="column.comingSoon"
36 class="ivyforms-flex ivyforms-align-items-center ivyforms-gap-8"
37 >
38 <div class="ivyforms-table__container__coming-soon-title">{{ column.title }}</div>
39 <ComingSoonBadge size="s" />
40 </div>
41 <span v-else>{{ column.title }}</span>
42
43 <!-- Sorting icons -->
44 <span v-if="column.sortable">
45 <span v-if="sortColumn === column.key">
46 <span v-if="sortOrder === 'asc'">
47 <IvyIcon
48 size="d"
49 outer-size="20px"
50 :name="isDarkMode ? 'sort-asc-dark' : 'sort-asc'"
51 category="table"
52 />
53 </span>
54 <span v-else-if="sortOrder === 'desc'">
55 <IvyIcon
56 size="d"
57 outer-size="20px"
58 :name="isDarkMode ? 'sort-desc-dark' : 'sort-desc'"
59 category="table"
60 />
61 </span>
62 </span>
63 <span v-else>
64 <IvyIcon
65 size="d"
66 outer-size="20px"
67 name="arrow-up-down-2"
68 category="arrows"
69 type="outline"
70 color="var(--map-base-dusk-symbol-2)"
71 />
72 </span>
73 </span>
74 </div>
75 </template>
76
77 <!-- Slot for empty table/no filters match -->
78 <template #empty>
79 <slot name="empty" :loading="props.loading" :column-count="formattedColumns.length" />
80 </template>
81 </ElTableV2>
82 </template>
83 </ElAutoResizer>
84 </IvyScrollbar>
85 </div>
86 </template>
87
88 <script lang="ts" setup>
89 import { computed, ref, h, onMounted, onUnmounted, watch, nextTick } from 'vue'
90 import type { TableColumn } from '@/views/_components/table/IvyTableColumn.ts'
91 import type { CellRenderProps } from '@/views/_components/table/IvyCellRendererProps.ts'
92 import IvyScrollbar from '@/views/_components/scrollbar/IvyScrollbar.vue'
93 import { useTableHover } from '@/views/_components/table/composables/useTableHover.ts'
94
95 type RowClassResult = string | string[] | undefined | null
96 type RowClassFn = (rowData: Record<string, unknown>) => RowClassResult
97
98 interface Props {
99 data: Array<Record<string, unknown>>
100 columns?: TableColumn[]
101 loading?: boolean
102 pagination?: boolean
103 groupBy?: string
104 fixed?: boolean
105 minTableWidth?: number
106 hover?: boolean
107 rowClass?: RowClassFn
108 }
109
110 const props = withDefaults(defineProps<Props>(), {
111 loading: false,
112 pagination: false,
113 columns: () => [],
114 groupBy: '',
115 minTableWidth: 240,
116 hover: true,
117 rowClass: undefined,
118 })
119
120 const emit = defineEmits<{
121 (e: 'sort-change', sortEvent: { column: string | null; order: 'asc' | 'desc' | null }): void
122 }>()
123
124 // Sort icons based on theme
125 const isDarkMode = ref(document.body.classList.contains('ivyforms-theme-dark'))
126
127 const observer = new MutationObserver(() => {
128 isDarkMode.value = document.body.classList.contains('ivyforms-theme-dark')
129 })
130
131 const tableContainerRef = ref<HTMLElement | null>(null)
132 const scrollbarRef = ref<InstanceType<typeof IvyScrollbar> | null>(null)
133 const tableHeight = ref<number>(550)
134
135 let resizeObserver: ResizeObserver | null = null
136
137 const updateTableHeight = () => {
138 const rowHeight = 50
139 const numRows = props.data.length + 1
140 const maxTableHeight = numRows * rowHeight
141
142 const tableContainer = tableContainerRef.value
143 const parentOfParent = tableContainer?.parentElement?.parentElement as HTMLElement | null
144 const parentOfParentHeight = parentOfParent ? parentOfParent.offsetHeight : 0
145 const finalMaxHeight = props.pagination ? parentOfParentHeight - 160 : parentOfParentHeight
146
147 const minHeight = numRows > 1 ? 0 : 550
148 const newHeight = Math.max(minHeight, Math.min(maxTableHeight, finalMaxHeight))
149
150 if (Math.abs(newHeight - tableHeight.value) > 1) {
151 tableHeight.value = newHeight
152 }
153 }
154
155 const updateDimensions = () => {
156 window.requestAnimationFrame(updateTableHeight)
157 }
158
159 const onWindowResize = () => updateDimensions()
160
161 onMounted(() => {
162 observer.observe(document.body, { attributes: true, attributeFilter: ['class'] })
163
164 if (!tableContainerRef.value) return
165
166 updateDimensions()
167 window.addEventListener('resize', onWindowResize)
168 resizeObserver = new window.ResizeObserver(() => updateDimensions())
169 resizeObserver.observe(tableContainerRef.value)
170 })
171
172 onUnmounted(() => {
173 window.removeEventListener('resize', onWindowResize)
174 resizeObserver?.disconnect()
175 observer.disconnect()
176 })
177
178 // Watch for data changes to update table height
179 watch(
180 () => props.data.length,
181 () => {
182 updateTableHeight()
183 },
184 )
185
186 // Watch for column changes to update scrollbar (fixes horizontal scrollbar not appearing after column visibility change)
187 watch(
188 () => props.columns?.filter((col) => col.visible).length,
189 () => {
190 nextTick(() => {
191 scrollbarRef.value?.update()
192 window.dispatchEvent(new Event('resize'))
193 })
194 },
195 )
196
197 // Internal hover handling (replaces per-page useTableHover usage)
198 const { hoveredRowId, syncHover } = useTableHover()
199
200 watch(
201 () => (props.hover ? props.data : null),
202 async () => {
203 if (props.hover) await syncHover()
204 },
205 { flush: 'post', deep: false, immediate: true },
206 )
207
208 // Format columns to use custom components inside headers and cells
209 const formattedColumns = computed(
210 () =>
211 props.columns
212 ?.filter((col) => col.visible)
213 .map((col) => {
214 const userCellRenderer = col.cellRenderer
215 return {
216 ...col,
217 sortable: col.sortable || false,
218 headerRenderer: col.headerRenderer || undefined,
219 cellRenderer: userCellRenderer
220 ? (cellProps: CellRenderProps) =>
221 userCellRenderer({ ...cellProps, hoveredRowId: hoveredRowId.value })
222 : (cellProps: CellRenderProps) => h('span', {}, cellProps.rowData[col.key]),
223 minWidth:
224 typeof col.minWidth === 'string' ? parseInt(col.minWidth, 10) || 100 : col.minWidth, // Ensure minWidth is a number
225 width: typeof col.width === 'string' ? parseInt(col.width, 10) || 100 : col.width, // Ensure width is a number
226 }
227 }) || [],
228 )
229
230 // Sorting state (only for UI purposes)
231 const sortColumn = ref<string | null>(null)
232 const sortOrder = ref<'asc' | 'desc' | null>(null)
233
234 // Emit sorting changes
235 const emitSortChange = (column) => {
236 if (!column.sortable) return
237
238 if (sortColumn.value === column.key) {
239 // Toggle order
240 sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
241 } else {
242 // New column selected, set ascending order
243 sortColumn.value = column.key
244 sortOrder.value = 'asc'
245 }
246
247 // Emit the sorting change event
248 const sortEvent = {
249 column: sortColumn.value,
250 order: sortOrder.value,
251 }
252 emit('sort-change', sortEvent)
253 }
254
255 // Custom row classes. When a `rowClass` prop is supplied it fully replaces the
256 // built-in unread/selected logic; otherwise the legacy defaults apply.
257 const getRowClass = ({ rowData }: { rowData: Record<string, unknown> }) => {
258 if (props.rowClass) {
259 const result = props.rowClass(rowData)
260 if (!result) return ''
261 return Array.isArray(result) ? result.join(' ') : result
262 }
263 const classes: string[] = []
264 if (rowData.status === 'unread') classes.push('ivyforms-unread-entry')
265 if (rowData.selected) classes.push('is-selected')
266 return classes.join(' ')
267 }
268
269 defineExpose({ hoveredRowId })
270 </script>
271
272 <style lang="scss">
273 .ivyforms-table__container {
274 position: relative;
275
276 &__coming-soon-title {
277 opacity: 0.5;
278 cursor: not-allowed;
279 }
280
281 // Horizontal scrollbar opacity to match vertical
282 .el-virtual-scrollbar {
283 bottom: unset !important;
284 }
285
286 .el-scrollbar__thumb {
287 opacity: var(--el-scrollbar-opacity, 0.3) !important;
288
289 &:hover {
290 background-color: var(
291 --el-scrollbar-hover-bg-color,
292 var(--el-text-color-secondary)
293 ) !important;
294 opacity: var(--el-scrollbar-hover-opacity, 0.5) !important;
295 }
296 }
297
298 .ivyforms-table__custom-header {
299 position: absolute;
300 top: 0;
301 left: 0;
302 right: 0;
303 z-index: 10;
304 background: var(--map-ground-level-1-foreground);
305 border-bottom: 1px solid var(--map-divider);
306 }
307
308 .el-auto-resizer {
309 &.loading {
310 width: 100%;
311 height: 100%;
312 min-width: 0;
313 min-height: 0;
314 overflow: hidden;
315 }
316 }
317
318 .ivyforms-table {
319 // Header
320 .el-table-v2__header {
321 border-bottom: 1px solid var(--map-divider);
322 background: var(--map-ground-level-1-foreground);
323 transition: none;
324
325 // Columns
326 .el-table-v2__header-cell {
327 display: flex;
328 background: var(--map-ground-level-1-foreground);
329 color: var(--map-base-text-0);
330 text-align: center;
331 font-size: 14px;
332 font-style: normal;
333 font-weight: 400;
334 line-height: 20px;
335
336 &:has(.el-checkbox) {
337 justify-content: center;
338 }
339
340 // Sortable
341 &.is-sortable {
342 span {
343 margin-left: 0 !important;
344 }
345 .el-table-v2__sort-icon {
346 svg {
347 display: none;
348 }
349 }
350 }
351 }
352 }
353
354 // Hide all checkbox labels
355 .el-checkbox__label {
356 display: none;
357 }
358
359 // Body
360 .el-table-v2__body {
361 background: var(--map-ground-level-1-foreground);
362 transition: none;
363
364 // Row cells with checkbox - modern browsers support
365 .el-table-v2__row-cell {
366 &:has(.el-checkbox) {
367 justify-content: center;
368 }
369 }
370
371 // Alternative approach for older browsers
372 .el-table-v2__row-cell.el-checkbox-cell {
373 justify-content: center;
374 }
375
376 // Rows
377 .el-table-v2__row {
378 display: flex;
379 height: 48px;
380 align-items: center;
381 flex-shrink: 0;
382 border-bottom: 1px solid var(--map-divider);
383 background: var(--map-ground-level-1-foreground);
384 color: var(--map-base-dusk-symbol-2);
385 transition: none;
386
387 &:hover,
388 &.hovered {
389 background: var(--map-hover);
390 }
391
392 // Selected
393 &.is-selected {
394 background:
395 linear-gradient(0deg, var(--map-accent-amber-o05) 0%, var(--map-accent-amber-o05) 100%),
396 var(--map-ground-level-1-foreground);
397 }
398 }
399 }
400 }
401 }
402
403 .el-skeleton {
404 &.ivyforms-skeleton {
405 margin: 1px;
406
407 .ivyforms-skeleton-item {
408 height: 48px;
409 }
410 }
411 }
412 </style>
413