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. 239
      packages/base/data/list-table-v2.vue
  6. 2
      tsconfig.json

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

@ -167,6 +167,29 @@ queueMicrotask(() => { @@ -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
<!-- What reviewers should check -->

3
bun.lock

@ -11,6 +11,7 @@ @@ -11,6 +11,7 @@
"dayjs": "^1.11.19",
"element-plus": "2.13.0",
"lodash-es": "^4.17.23",
"ts-pattern": "^5.9.0",
"vue": "^3.5.26",
"vue-class-component": "^8.0.0-0",
"vue-router": "^4.6.4",
@ -1698,6 +1699,8 @@ @@ -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-pattern": ["ts-pattern@5.9.0", "", {}, "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"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({ @@ -37,7 +37,7 @@ const example = reactive({
// Generate 100 rows of data across multiple pages
const generateData = () => {
const rows = [];
const rows: Array<Record<string, any>> = [];
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 } });
for (let i = 1; i <= 100; i++) {

1
package.json

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

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

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

2
tsconfig.json

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

Loading…
Cancel
Save