Browse Source

feat(list-table-v2): expose cellRenderer and headerCellRenderer column attributes

- Add cellRenderer and headerCellRenderer to ListTableColumn interface
- Use functional components (MiniTableCell, MiniTableHeader) to render VNodes properly in mini-table
- Add ts-pattern dependency for type-safe pattern matching
- Fix mini-table CSS to match el-table-v2 cell rendering for accurate height measurement:
  - Add font-size: var(--el-font-size-base)
  - Change flex-direction to row, align-items to center
  - Add padding: 4px to mini-cell
  - Add justify-content: center to mini-header-cell
- Document Vue 3 template VNode rendering pattern in quality-guidelines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dev
hechang27-sprt 3 months ago
parent
commit
3c97cd7574
  1. 23
      .trellis/spec/frontend/quality-guidelines.md
  2. 3
      bun.lock
  3. 2
      examples/view/base/table-v2.vue
  4. 1
      package.json
  5. 241
      packages/base/data/list-table-v2.vue
  6. 2
      tsconfig.json

23
.trellis/spec/frontend/quality-guidelines.md

@ -167,6 +167,29 @@ queueMicrotask(() => {
--- ---
### Vue 3 Template: VNode Rendering
**Problem**: Using `{renderFunction()}` in Vue template renders the function's string representation, not the returned VNode.
**Why**: In Vue 3 templates, `{xxx}` is treated as literal text interpolation, not JSX expression.
**Correct Pattern**: Use functional components that delegate to render functions:
```typescript
// Define functional component that delegates to render function
const MiniTableCell = (params: CellRendererParams<T>) => renderCellContent(params);
const MiniTableHeader = (params: HeaderCellRendererParams<T>) => renderHeaderCellContent(params);
// Use in template as component (NOT as {MiniTableCell(...)})
<template>
<MiniTableCell :cellData="value" :column :rowData />
</template>
```
**Why this works**: Vue functional components receive VNode params and return VNodes, which Vue renders correctly. Calling `renderFunction()` directly in template gives `{[object Promise]}` or stringified function output.
---
## Code Review Checklist ## Code Review Checklist
<!-- What reviewers should check --> <!-- What reviewers should check -->

3
bun.lock

@ -11,6 +11,7 @@
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"element-plus": "2.13.0", "element-plus": "2.13.0",
"lodash-es": "^4.17.23", "lodash-es": "^4.17.23",
"ts-pattern": "^5.9.0",
"vue": "^3.5.26", "vue": "^3.5.26",
"vue-class-component": "^8.0.0-0", "vue-class-component": "^8.0.0-0",
"vue-router": "^4.6.4", "vue-router": "^4.6.4",
@ -1698,6 +1699,8 @@
"ts-loader": ["ts-loader@9.5.4", "", { "dependencies": { "chalk": "^4.1.0", "enhanced-resolve": "^5.0.0", "micromatch": "^4.0.0", "semver": "^7.3.4", "source-map": "^0.7.4" }, "peerDependencies": { "typescript": "*", "webpack": "^5.0.0" } }, "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ=="], "ts-loader": ["ts-loader@9.5.4", "", { "dependencies": { "chalk": "^4.1.0", "enhanced-resolve": "^5.0.0", "micromatch": "^4.0.0", "semver": "^7.3.4", "source-map": "^0.7.4" }, "peerDependencies": { "typescript": "*", "webpack": "^5.0.0" } }, "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ=="],
"ts-pattern": ["ts-pattern@5.9.0", "", {}, "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-fest": ["type-fest@0.6.0", "", {}, "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg=="], "type-fest": ["type-fest@0.6.0", "", {}, "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg=="],

2
examples/view/base/table-v2.vue

@ -37,7 +37,7 @@ const example = reactive({
// Generate 100 rows of data across multiple pages // Generate 100 rows of data across multiple pages
const generateData = () => { const generateData = () => {
const rows = []; const rows: Array<Record<string, any>> = [];
const now = Math.floor(Date.now() / 1000); // Unix timestamp in seconds const now = Math.floor(Date.now() / 1000); // Unix timestamp in seconds
const lorem = new LoremIpsum({ sentencesPerParagraph: { min: 2, max: 5 }, wordsPerSentence: { min: 5, max: 20 } }); const lorem = new LoremIpsum({ sentencesPerParagraph: { min: 2, max: 5 }, wordsPerSentence: { min: 5, max: 20 } });
for (let i = 1; i <= 100; i++) { for (let i = 1; i <= 100; i++) {

1
package.json

@ -95,6 +95,7 @@
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"element-plus": "2.13.0", "element-plus": "2.13.0",
"lodash-es": "^4.17.23", "lodash-es": "^4.17.23",
"ts-pattern": "^5.9.0",
"vue": "^3.5.26", "vue": "^3.5.26",
"vue-class-component": "^8.0.0-0", "vue-class-component": "^8.0.0-0",
"vue-router": "^4.6.4", "vue-router": "^4.6.4",

241
packages/base/data/list-table-v2.vue

@ -7,18 +7,30 @@
<!-- Mini header row --> <!-- Mini header row -->
<div ref="miniHeaderRef" class="mini-row mini-header"> <div ref="miniHeaderRef" class="mini-row mini-header">
<div <div
v-for="item in prop.columns || []" v-for="(column, columnIndex) in tableColumns || []"
:key="item.key" :key="column.key"
class="mini-cell mini-header-cell" class="mini-cell mini-header-cell"
:style="getMiniCellStyle(item)" :style="getMiniCellStyle(column)"
> >
<span class="mini-header-content">{ item.name || (item.i18n ? t(item.i18n) : item.key) }</span> <MiniTableHeader :cellData="undefined as T" :columnIndex :columns="tableColumns" :column />
</div> </div>
</div> </div>
<!-- Mini data rows --> <!-- Mini data rows -->
<div v-for="(row, rowIdx) in miniTableData" :key="rowIdx" class="mini-row"> <div v-for="(rowData, rowIndex) in miniTableData" :key="rowIndex" class="mini-row">
<div v-for="item in prop.columns || []" :key="item.key" class="mini-cell" :style="getMiniCellStyle(item)"> <div
{renderCellContent(item, lodash.get(row, item.dataKey || item.key), row, slots)} v-for="(column, columnIndex) in tableColumns || []"
:key="column.key"
class="mini-cell"
:style="getMiniCellStyle(column)"
>
<MiniTableCell
:cellData="lodash.get(rowData, String(column.dataKey || column.key))"
:columnIndex
:columns="tableColumns"
:column
:rowData
:rowIndex
/>
</div> </div>
</div> </div>
</div> </div>
@ -61,13 +73,34 @@
</div> </div>
</template> </template>
<script lang="tsx" setup> <script lang="tsx" setup generic="T">
import { useStore } from "vuex"; import { useStore } from "vuex";
import { ref, computed, watch, h, onMounted, onUpdated, onUnmounted, useSlots, renderSlot, nextTick } from "vue"; import {
ref,
computed,
watch,
h,
onMounted,
onUpdated,
onUnmounted,
useSlots,
renderSlot,
nextTick,
VNode,
StyleValue,
} from "vue";
import { useI18n } from "vue3-i18n"; import { useI18n } from "vue3-i18n";
import { ElAutoResizer, ElTableV2 } from "element-plus"; import { Column, ElAutoResizer, ElTableV2 } from "element-plus";
import * as lodash from "lodash-es"; import * as lodash from "lodash-es";
import TzDateTime from "../item/tzDateTime.vue"; import TzDateTime from "../item/tzDateTime.vue";
import {
CellRenderer,
CellRendererParams,
HeaderCellRenderer,
HeaderCellRendererParams,
} from "element-plus/es/components/table-v2/src/types.mjs";
import { match, P } from "ts-pattern";
import { FixedDir } from "element-plus/es/components/table-v2/src/constants.mjs";
const slots = useSlots(); const slots = useSlots();
@ -114,7 +147,7 @@ interface TzDateTimeConfig {
type?: "iso8601" | "unix" | "unixMillis"; type?: "iso8601" | "unix" | "unixMillis";
} }
interface TableColumn { interface ListTableColumn<T> {
key: string; key: string;
dataKey?: string; dataKey?: string;
name?: string; name?: string;
@ -128,13 +161,16 @@ interface TableColumn {
dict?: string; dict?: string;
timestamp?: TimestampValue; timestamp?: TimestampValue;
filesize?: boolean; filesize?: boolean;
// Custom renderers - JSX-returning functions that override slot/default rendering
cellRenderer?: CellRenderer<T>;
headerCellRenderer?: HeaderCellRenderer<T>;
[others: string]: any; [others: string]: any;
} }
interface Props { interface Props {
data?: any; data?: any;
columns?: TableColumn[]; columns?: ListTableColumn<T>[];
page?: boolean; page?: boolean;
height?: number | string; height?: number | string;
maxHeight?: number | string; maxHeight?: number | string;
@ -182,17 +218,6 @@ const pageData = computed(() => {
return []; return [];
}); });
// Probe row text extraction (same logic as cellRenderer but returns text only)
const getProbeCellText = (rowData: any, item: TableColumn): string => {
const value = lodash.get(rowData, item.dataKey || item.key);
if (item.dict) return formatterByDist(item.dict, value);
if (item.timestamp) return formatStamp(value);
if (item.filesize) return formatFileSize(value);
if (rowData.scheme) return formatterByDist(rowData.scheme + "_" + (item.dataKey || item.key), value);
return getValue(value);
};
// Whether to use probe row for dynamic height measurement // Whether to use probe row for dynamic height measurement
// Only use probe row when rowHeight is NOT explicitly specified // Only use probe row when rowHeight is NOT explicitly specified
const shouldUseProbeRow = computed(() => { const shouldUseProbeRow = computed(() => {
@ -368,23 +393,23 @@ const resolveTimestampProps = (ts: TimestampValue): TzDateTimeConfig | null => {
return config; return config;
}; };
const formatterByDist = (dictKey: string, value: any) => { const formatterByDist = (dictKey: string, cellData: any) => {
if (!dictKey) { if (!dictKey) {
return getValue(value); return getValue(cellData);
} }
const mapping = state.dict[dictKey]; const mapping = state.dict[dictKey];
if (mapping == null) { if (mapping == null) {
return getValue(value); return getValue(cellData);
} }
return mapping[value] == null ? value : mapping[value]; return mapping[cellData] == null ? cellData : mapping[cellData];
}; };
const formatCellValue = (value: any, item: TableColumn, row: any) => { const formatCellValue = (cellData: T, column: ListTableColumn<T>, rowData: any) => {
if (item.dict) return formatterByDist(item.dict, value); if (column.dict) return formatterByDist(column.dict, cellData);
if (item.timestamp) return formatStamp(value); if (column.timestamp) return formatStamp(cellData);
if (item.filesize) return formatFileSize(value); if (column.filesize) return formatFileSize(cellData);
if (row.scheme) return formatterByDist(row.scheme + "_" + (item.dataKey || item.key), value); if (rowData.scheme) return formatterByDist(rowData.scheme + "_" + (column.dataKey || column.key), cellData);
return getValue(value); return getValue(cellData);
}; };
const handleSizeChange = (val: number) => { const handleSizeChange = (val: number) => {
@ -418,54 +443,75 @@ const onCellClick = ({ row, column }: { row: any; column: any }) => {
// Build columns for el-table-v2 // Build columns for el-table-v2
const tableColumns = computed(() => { const tableColumns = computed(() => {
return prop.columns.map((item: TableColumn) => { return prop.columns.map((column): Column<T> => {
const col: any = { const col: Column<T> = {
key: item.key, key: column.key,
title: item.name || (item.i18n ? t(item.i18n) : item.key), title: column.name || (column.i18n ? t(column.i18n) : column.key),
dataKey: item.dataKey || item.key, dataKey: column.dataKey || column.key,
align: item.align || "center", align: column.align || "center",
fixed: item.fixed, fixed: match(column.fixed)
.with("left", () => FixedDir.LEFT)
.with("right", () => FixedDir.RIGHT)
.with(false, () => undefined)
.otherwise((value) => value),
minWidth: match(column.minWidth)
.with(P.number, (n) => n)
.with(
P.string,
(str) => !isNaN(parseInt(str)),
(str) => parseInt(str)
)
.otherwise(() => 120),
// If width is explicitly provided, use it; otherwise use flexGrow to auto-distribute
...match(column.width)
.with(P.number, (width) => ({ width }))
.with(
P.string,
(str) => !isNaN(parseInt(str)),
(str) => ({ width: parseInt(str) })
)
.otherwise(() => ({ width: 120, flexGrow: 1 })),
}; };
// If width is explicitly provided, use it; otherwise use flexGrow to auto-distribute // Cell renderer - uses renderCellContent which handles slot, cellRenderer, and built-in types
if (item.width !== undefined) { col.cellRenderer = renderCellContent;
col.width = typeof item.width === "number" ? item.width : parseInt(String(item.width)) || 120;
} else {
// Use flexGrow: 1 to auto-expand columns to fill available width
col.flexGrow = 1;
col.width = 120; // minimum width
}
if (item.minWidth !== undefined) {
col.minWidth = typeof item.minWidth === "number" ? item.minWidth : parseInt(String(item.minWidth)) || 120;
}
// Cell renderer - el-table-v2 uses cellRenderer function // Header cell renderer - use custom headerCellRenderer if provided
col.cellRenderer = ({ cellData, rowData }: { cellData: any; rowData: any }) => { col.headerCellRenderer = undefined; // TODO
return renderCellContent(item, cellData, rowData, slots);
};
col._listTableColumn = column;
return col; return col;
}); });
}); });
const MiniTableCell = (params: CellRendererParams<T>) => renderCellContent(params);
const MiniTableHeader = (params: HeaderCellRendererParams<T>) => renderHeaderCellContent(params);
// Shared cell renderer - used by both el-table-v2 and mini table for consistent rendering // Shared cell renderer - used by both el-table-v2 and mini table for consistent rendering
const renderCellContent = (item: TableColumn, value: any, row: any, slots: ReturnType<typeof useSlots>) => { const renderCellContent = (params: CellRendererParams<T>) => {
const slotName = item.key; const { cellData, rowData, column: elColumn } = params;
const column: ListTableColumn<T> = elColumn._listTableColumn;
const slotName = column.key;
// If custom cellRenderer is provided, use it (highest priority)
if (column.cellRenderer) {
return column.cellRenderer(params);
}
// If column has slot=true, render the parent's slot content // If column has slot=true, render the parent's slot content
if (item.slot && slots[slotName]) { if (column.slot && slots[slotName]) {
return renderSlot(slots, slotName, { row }); return renderSlot(slots, slotName, { row: rowData });
} }
// Handle timestamp display using TzDateTime component // Handle timestamp display using TzDateTime component
if (item.timestamp) { if (column.timestamp && (typeof cellData === "string" || typeof cellData === "number")) {
const tzProps = resolveTimestampProps(item.timestamp); const tzProps = resolveTimestampProps(column.timestamp);
if (tzProps) { if (tzProps) {
const { valueFormat, valueTz, displayFormat, locale, type } = tzProps; const { valueFormat, valueTz, displayFormat, locale, type } = tzProps;
return ( return (
<TzDateTime <TzDateTime
value={value} value={cellData}
valueFormat={valueFormat} valueFormat={valueFormat}
valueTz={valueTz} valueTz={valueTz}
displayFormat={displayFormat} displayFormat={displayFormat}
@ -477,23 +523,33 @@ const renderCellContent = (item: TableColumn, value: any, row: any, slots: Retur
} }
// Handle dict display // Handle dict display
if (item.dict) { if (column.dict) {
return <span class="cell-text">{formatterByDist(item.dict, value)}</span>; return <span class="mini-cell-text">{formatterByDist(column.dict, cellData)}</span>;
} }
// Handle formatting // Handle formatting
const formatted = formatCellValue(value, item, row); const formatted = formatCellValue(cellData, column, rowData);
return <span class="cell-text">{formatted}</span>; return <span class="mini-cell-text">{formatted}</span>;
}; };
// Get mini cell style - mirrors the real table's column width/flex distribution const renderHeaderCellContent = (params: HeaderCellRendererParams<T>) => {
const getMiniCellStyle = (item: TableColumn): Record<string, string> => { const { column: elColumn } = params;
if (item.width !== undefined) { const column: ListTableColumn<T> = elColumn._listTableColumn;
const w = typeof item.width === "number" ? item.width : parseInt(String(item.width)) || 120;
return { width: `${w}px`, flex: "none" }; if (column.headerCellRenderer) {
return column.headerCellRenderer(params);
} }
// Use flex: 1 to auto-expand columns to fill available width (same as flexGrow: 1) const header = match(column)
return { flex: "1", minWidth: "120px" }; .with({ name: P.select(P.string) }, (name) => name)
.with({ i18n: P.select(P.string) }, (i18n) => t(i18n))
.otherwise((c) => c.key);
return <span class="mini-header-cell-text">{header}</span>;
};
// Get mini cell style - mirrors the real table's column width/flex distribution
const getMiniCellStyle = ({ width, minWidth, maxWidth, flexGrow, flexShrink }: Column<T>): StyleValue => {
return { width: `${width}px`, minWidth: `${minWidth}px`, maxWidth: `${maxWidth}px`, flexGrow, flexShrink };
}; };
</script> </script>
@ -514,6 +570,7 @@ const getMiniCellStyle = (item: TableColumn): Record<string, string> => {
left: -9999px; left: -9999px;
top: 0; top: 0;
width: 100%; width: 100%;
font-size: var(--el-font-size-base);
} }
.mini-table-inner { .mini-table-inner {
@ -524,14 +581,17 @@ const getMiniCellStyle = (item: TableColumn): Record<string, string> => {
.mini-row { .mini-row {
display: flex; display: flex;
align-items: center; align-items: stretch;
padding: v-bind("state.size.tablePad"); padding: v-bind("state.size.tablePad");
border-right: var(--el-table-border); border-right: var(--el-table-border);
border-bottom: var(--el-table-border); border-bottom: var(--el-table-border);
background: v-bind("state.style.tableBg"); background: v-bind("state.style.tableBg");
color: v-bind("state.style.tableColor"); color: v-bind("state.style.tableColor");
font-size: var(--el-font-size-base);
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
width: 100%;
min-height: min-content;
} }
.mini-header { .mini-header {
@ -539,28 +599,27 @@ const getMiniCellStyle = (item: TableColumn): Record<string, string> => {
font-weight: bold; font-weight: bold;
} }
.mini-header-content { .mini-header-cell {
overflow: visible; justify-content: center;
text-overflow: clip;
white-space: normal;
word-break: break-word;
} }
.mini-header-cell { .mini-header-cell-text {
overflow: visible; display: block;
align-self: stretch;
} }
.mini-cell { .mini-cell {
display: flex;
flex-direction: row;
align-items: center;
padding: 4px;
box-sizing: border-box; box-sizing: border-box;
white-space: normal;
word-break: normal;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.mini-cell-content { .mini-cell-text {
overflow: hidden; display: block;
text-overflow: ellipsis;
} }
.my-table { .my-table {
@ -615,12 +674,6 @@ const getMiniCellStyle = (item: TableColumn): Record<string, string> => {
background: v-bind("state.style.tableChildBg") !important; background: v-bind("state.style.tableChildBg") !important;
} }
.my-table :deep(.cell-text) {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.my-pagination { .my-pagination {
display: flex; display: flex;
justify-content: center; justify-content: center;

2
tsconfig.json

@ -16,7 +16,7 @@
"sourceMap": true, "sourceMap": true,
"baseUrl": "./", "baseUrl": "./",
"types": [ "types": [
"webpack-env" "webpack-env", "element-plus/global"
], ],
"paths": { "paths": {
"@/": [ "@/": [

Loading…
Cancel
Save