This commit is contained in:
pa
2026-03-04 23:36:04 +09:00
parent 1be9d13cd4
commit 3de0e30ad2
10 changed files with 816 additions and 248 deletions

View File

@@ -264,6 +264,13 @@
} from '../pagination';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
import {
getColStyle,
getToggleableColumns,
isReorderable as isReorderableHelper,
isSpacer,
resolveHeaderLabel
} from './dataTableHelpers.js';
import { ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuTrigger } from '../context-menu';
import DataTableEmpty from './DataTableEmpty.vue';
@@ -396,20 +403,7 @@
return null;
};
const isSpacer = (col) => col?.id === '__spacer';
const isStretch = (col) => {
return !!col?.columnDef?.meta?.stretch;
};
const isReorderable = (header) => {
const col = header?.column;
if (!col) return false;
if (isSpacer(col)) return false;
if (getPinnedState(col)) return false;
if (col.columnDef?.meta?.disableReorder) return false;
return true;
};
const isReorderable = (header) => isReorderableHelper(header, getPinnedState);
const reorderableIndex = (headers, actualIndex) => {
let sortableIdx = 0;
@@ -463,33 +457,9 @@
const toggleableColumns = computed(() => {
const cols = props.table?.getAllLeafColumns?.() ?? [];
return cols.filter((col) => {
if (isSpacer(col)) return false;
if (isStretch(col)) return false;
if (col.columnDef?.meta?.disableVisibilityToggle) return false;
if (!col.columnDef?.meta?.label) return false;
return true;
});
return getToggleableColumns(cols);
});
const resolveHeaderLabel = (col) => {
const label = col?.columnDef?.meta?.label;
if (typeof label === 'function') return label();
return label ?? col?.id ?? '';
};
const getColStyle = (col) => {
if (isSpacer(col)) return { width: '0px' };
if (isStretch(col)) {
return null;
}
const size = col?.getSize?.();
if (!Number.isFinite(size)) return null;
return { width: `${size}px` };
};
const getHeaderClass = (header) => {
const columnDef = header?.column?.columnDef;
const meta = columnDef?.meta ?? {};

View File

@@ -0,0 +1,184 @@
import { describe, expect, it } from 'vitest';
import {
getColStyle,
getToggleableColumns,
isReorderable,
isSpacer,
isStretch,
resolveHeaderLabel
} from '../dataTableHelpers';
// Helper to create a mock TanStack column instance
const mockCol = (id, meta = {}, overrides = {}) => ({
id,
columnDef: { meta },
...overrides
});
describe('isSpacer', () => {
it('returns true for __spacer column', () => {
expect(isSpacer({ id: '__spacer' })).toBe(true);
});
it('returns false for regular column', () => {
expect(isSpacer({ id: 'name' })).toBe(false);
});
it('returns false for null/undefined', () => {
expect(isSpacer(null)).toBe(false);
expect(isSpacer(undefined)).toBe(false);
});
});
describe('isStretch', () => {
it('returns true when meta.stretch is true', () => {
expect(isStretch(mockCol('detail', { stretch: true }))).toBe(true);
});
it('returns false when meta.stretch is absent', () => {
expect(isStretch(mockCol('name'))).toBe(false);
});
it('returns false for null column', () => {
expect(isStretch(null)).toBe(false);
});
});
describe('resolveHeaderLabel', () => {
it('returns string label from meta', () => {
expect(resolveHeaderLabel(mockCol('name', { label: 'Name' }))).toBe(
'Name'
);
});
it('calls function label and returns result', () => {
const col = mockCol('name', { label: () => 'Translated Name' });
expect(resolveHeaderLabel(col)).toBe('Translated Name');
});
it('falls back to column id when no label', () => {
expect(resolveHeaderLabel(mockCol('displayName'))).toBe('displayName');
});
it('returns empty string for null column', () => {
expect(resolveHeaderLabel(null)).toBe('');
});
it('returns empty string for undefined column', () => {
expect(resolveHeaderLabel(undefined)).toBe('');
});
});
describe('getToggleableColumns', () => {
it('includes columns with meta.label', () => {
const cols = [mockCol('name', { label: 'Name' })];
expect(getToggleableColumns(cols)).toHaveLength(1);
});
it('excludes spacer columns', () => {
const cols = [
mockCol('name', { label: 'Name' }),
{ id: '__spacer', columnDef: { meta: { label: 'Spacer' } } }
];
expect(getToggleableColumns(cols)).toHaveLength(1);
expect(getToggleableColumns(cols)[0].id).toBe('name');
});
it('excludes stretch columns', () => {
const cols = [
mockCol('name', { label: 'Name' }),
mockCol('detail', { stretch: true, label: 'Detail' })
];
expect(getToggleableColumns(cols)).toHaveLength(1);
});
it('excludes columns with disableVisibilityToggle', () => {
const cols = [
mockCol('name', { label: 'Name' }),
mockCol('hidden', {
label: 'Hidden',
disableVisibilityToggle: true
})
];
expect(getToggleableColumns(cols)).toHaveLength(1);
});
it('excludes columns without meta.label', () => {
const cols = [
mockCol('name', { label: 'Name' }),
mockCol('icon'),
mockCol('expand', {})
];
expect(getToggleableColumns(cols)).toHaveLength(1);
});
it('returns empty array for non-array input', () => {
expect(getToggleableColumns(null)).toEqual([]);
});
it('returns empty array when all columns are excluded', () => {
const cols = [
{ id: '__spacer', columnDef: { meta: {} } },
mockCol('icon')
];
expect(getToggleableColumns(cols)).toEqual([]);
});
});
describe('getColStyle', () => {
it('returns width 0px for spacer column', () => {
expect(getColStyle({ id: '__spacer' })).toEqual({ width: '0px' });
});
it('returns null for stretch column', () => {
expect(getColStyle(mockCol('detail', { stretch: true }))).toBeNull();
});
it('returns width from getSize()', () => {
const col = { ...mockCol('name'), getSize: () => 200 };
expect(getColStyle(col)).toEqual({ width: '200px' });
});
it('returns null when getSize returns non-finite', () => {
const col = { ...mockCol('name'), getSize: () => NaN };
expect(getColStyle(col)).toBeNull();
});
it('returns null when getSize is missing', () => {
expect(getColStyle(mockCol('name'))).toBeNull();
});
});
describe('isReorderable', () => {
const noPinning = () => false;
it('returns true for normal column', () => {
const header = { column: mockCol('name') };
expect(isReorderable(header, noPinning)).toBe(true);
});
it('returns false for spacer column', () => {
const header = { column: { id: '__spacer', columnDef: { meta: {} } } };
expect(isReorderable(header, noPinning)).toBe(false);
});
it('returns false for pinned column', () => {
const header = { column: mockCol('name') };
const isPinned = () => true;
expect(isReorderable(header, isPinned)).toBe(false);
});
it('returns false for columns with disableReorder', () => {
const header = { column: mockCol('name', { disableReorder: true }) };
expect(isReorderable(header, noPinning)).toBe(false);
});
it('returns false for null header', () => {
expect(isReorderable(null, noPinning)).toBe(false);
});
it('returns false for header without column', () => {
expect(isReorderable({}, noPinning)).toBe(false);
});
});

View File

@@ -0,0 +1,77 @@
/**
* Pure helper functions for DataTableLayout.
* Extracted for testability.
*/
/**
* @param {object} col - TanStack column instance
* @returns {boolean}
*/
export function isSpacer(col) {
return col?.id === '__spacer';
}
/**
* @param {object} col - TanStack column instance
* @returns {boolean}
*/
export function isStretch(col) {
return !!col?.columnDef?.meta?.stretch;
}
/**
* Resolves a column's display label for the visibility menu.
* Supports both string and function labels (for lazy i18n).
* @param {object} col - TanStack column instance
* @returns {string}
*/
export function resolveHeaderLabel(col) {
const label = col?.columnDef?.meta?.label;
if (typeof label === 'function') return label();
return label ?? col?.id ?? '';
}
/**
* Filters columns to determine which are toggleable in the visibility menu.
* @param {Array} cols - Array of TanStack column instances
* @returns {Array}
*/
export function getToggleableColumns(cols) {
if (!Array.isArray(cols)) return [];
return cols.filter((col) => {
if (isSpacer(col)) return false;
if (isStretch(col)) return false;
if (col.columnDef?.meta?.disableVisibilityToggle) return false;
if (!col.columnDef?.meta?.label) return false;
return true;
});
}
/**
* Computes the style object for a column's <col> element.
* @param {object} col - TanStack column instance
* @returns {object|null}
*/
export function getColStyle(col) {
if (isSpacer(col)) return { width: '0px' };
if (isStretch(col)) return null;
const size = col?.getSize?.();
if (!Number.isFinite(size)) return null;
return { width: `${size}px` };
}
/**
* Determines if a header can be reordered via drag-and-drop.
* @param {object} header - TanStack header instance
* @param {function} getPinnedState - function to check if column is pinned
* @returns {boolean}
*/
export function isReorderable(header, getPinnedState) {
const col = header?.column;
if (!col) return false;
if (isSpacer(col)) return false;
if (getPinnedState?.(col)) return false;
if (col.columnDef?.meta?.disableReorder) return false;
return true;
}

View File

@@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest';
import {
filterOrderByColumns,
filterSizingByColumns,
filterSortingByColumns,
filterVisibilityByColumns,
findStretchColumnId,
getColumnId,
safeJsonParse,
withSpacerColumn
} from '../useVrcxVueTable';
const cols = (...ids) => ids.map((id) => ({ id }));
describe('safeJsonParse', () => {
it('parses valid JSON', () => {
expect(safeJsonParse('{"a":1}')).toEqual({ a: 1 });
});
it('returns null for invalid JSON', () => {
expect(safeJsonParse('not json')).toBeNull();
});
it('returns null for empty string', () => {
expect(safeJsonParse('')).toBeNull();
});
it('returns null for null/undefined', () => {
expect(safeJsonParse(null)).toBeNull();
expect(safeJsonParse(undefined)).toBeNull();
});
});
describe('filterSizingByColumns', () => {
it('keeps only keys matching column IDs', () => {
const sizing = { name: 200, date: 150, removed: 100 };
expect(filterSizingByColumns(sizing, cols('name', 'date'))).toEqual({
name: 200,
date: 150
});
});
it('returns empty object for null sizing', () => {
expect(filterSizingByColumns(null, cols('a'))).toEqual({});
});
it('returns empty object for non-object sizing', () => {
expect(filterSizingByColumns('bad', cols('a'))).toEqual({});
});
it('returns empty object for null columns', () => {
expect(filterSizingByColumns({ a: 1 }, null)).toEqual({});
});
});
describe('filterSortingByColumns', () => {
it('keeps entries with valid column IDs', () => {
const sorting = [
{ id: 'name', desc: false },
{ id: 'removed', desc: true }
];
expect(filterSortingByColumns(sorting, cols('name', 'date'))).toEqual([
{ id: 'name', desc: false }
]);
});
it('returns empty array for non-array input', () => {
expect(filterSortingByColumns(null, cols('a'))).toEqual([]);
expect(filterSortingByColumns('bad', cols('a'))).toEqual([]);
});
it('returns empty array for null columns', () => {
expect(
filterSortingByColumns([{ id: 'a', desc: false }], null)
).toEqual([]);
});
});
describe('filterOrderByColumns', () => {
it('keeps IDs present in columns', () => {
expect(
filterOrderByColumns(
['date', 'removed', 'name'],
cols('name', 'date')
)
).toEqual(['date', 'name']);
});
it('returns empty array for non-array input', () => {
expect(filterOrderByColumns(null, cols('a'))).toEqual([]);
expect(filterOrderByColumns({}, cols('a'))).toEqual([]);
});
it('returns empty array for null columns', () => {
expect(filterOrderByColumns(['a'], null)).toEqual([]);
});
});
describe('filterVisibilityByColumns', () => {
it('keeps keys matching column IDs', () => {
const vis = { name: false, removed: true, date: false };
expect(filterVisibilityByColumns(vis, cols('name', 'date'))).toEqual({
name: false,
date: false
});
});
it('returns empty object for null visibility', () => {
expect(filterVisibilityByColumns(null, cols('a'))).toEqual({});
});
it('returns empty object for non-object visibility', () => {
expect(filterVisibilityByColumns(42, cols('a'))).toEqual({});
});
it('returns empty object for null columns', () => {
expect(filterVisibilityByColumns({ a: true }, null)).toEqual({});
});
});
describe('getColumnId', () => {
it('returns id when present', () => {
expect(getColumnId({ id: 'foo' })).toBe('foo');
});
it('falls back to accessorKey', () => {
expect(getColumnId({ accessorKey: 'bar' })).toBe('bar');
});
it('prefers id over accessorKey', () => {
expect(getColumnId({ id: 'foo', accessorKey: 'bar' })).toBe('foo');
});
it('returns null for null/undefined', () => {
expect(getColumnId(null)).toBeNull();
expect(getColumnId(undefined)).toBeNull();
});
it('returns null for empty object', () => {
expect(getColumnId({})).toBeNull();
});
});
describe('findStretchColumnId', () => {
it('returns the ID of the stretch column', () => {
const columns = [
{ id: 'a' },
{ id: 'b', meta: { stretch: true } },
{ id: 'c' }
];
expect(findStretchColumnId(columns)).toBe('b');
});
it('returns first stretch column when multiple exist', () => {
const columns = [
{ id: 'x', meta: { stretch: true } },
{ id: 'y', meta: { stretch: true } }
];
expect(findStretchColumnId(columns)).toBe('x');
});
it('returns null when no stretch column exists', () => {
expect(findStretchColumnId([{ id: 'a' }, { id: 'b' }])).toBeNull();
});
it('returns null for non-array input', () => {
expect(findStretchColumnId(null)).toBeNull();
expect(findStretchColumnId('bad')).toBeNull();
});
it('falls back to accessorKey for stretch column', () => {
const columns = [{ accessorKey: 'detail', meta: { stretch: true } }];
expect(findStretchColumnId(columns)).toBe('detail');
});
});
describe('withSpacerColumn', () => {
const baseCols = [{ id: 'a' }, { id: 'b' }];
it('appends spacer column at the end when no stretchAfterId', () => {
const result = withSpacerColumn(baseCols, true);
expect(result).toHaveLength(3);
expect(result[2].id).toBe('__spacer');
});
it('uses custom spacerId', () => {
const result = withSpacerColumn(baseCols, true, 'custom_spacer');
expect(result[2].id).toBe('custom_spacer');
});
it('inserts spacer after stretchAfterId column', () => {
const columns = [{ id: 'x' }, { id: 'stretch' }, { id: 'y' }];
const result = withSpacerColumn(columns, true, '__spacer', 'stretch');
expect(result.map((c) => c.id)).toEqual([
'x',
'stretch',
'__spacer',
'y'
]);
});
it('returns original columns when disabled', () => {
const result = withSpacerColumn(baseCols, false);
expect(result).toBe(baseCols);
});
it('does not add duplicate spacer', () => {
const columns = [{ id: 'a' }, { id: '__spacer' }];
const result = withSpacerColumn(columns, true);
expect(result).toBe(columns);
expect(result).toHaveLength(2);
});
it('returns non-array input as-is', () => {
expect(withSpacerColumn(null, true)).toBeNull();
});
it('appends spacer when stretchAfterId not found', () => {
const result = withSpacerColumn(baseCols, true, '__spacer', 'missing');
expect(result).toHaveLength(3);
expect(result[2].id).toBe('__spacer');
});
it('spacer column has correct defaults', () => {
const result = withSpacerColumn(baseCols, true);
const spacer = result[2];
expect(spacer.enableSorting).toBe(false);
expect(spacer.size).toBe(0);
expect(spacer.minSize).toBe(0);
expect(spacer.header()).toBeNull();
expect(spacer.cell()).toBeNull();
});
});

View File

@@ -13,7 +13,7 @@ import { computed, ref, unref, watch } from 'vue';
*
* @param str
*/
function safeJsonParse(str) {
export function safeJsonParse(str) {
if (!str) {
return null;
}
@@ -44,7 +44,7 @@ function debounce(fn, wait) {
* @param sizing
* @param columns
*/
function filterSizingByColumns(sizing, columns) {
export function filterSizingByColumns(sizing, columns) {
if (!sizing || typeof sizing !== 'object') {
return {};
}
@@ -63,7 +63,7 @@ function filterSizingByColumns(sizing, columns) {
* @param sorting
* @param columns
*/
function filterSortingByColumns(sorting, columns) {
export function filterSortingByColumns(sorting, columns) {
if (!Array.isArray(sorting)) {
return [];
}
@@ -76,7 +76,7 @@ function filterSortingByColumns(sorting, columns) {
* @param order
* @param columns
*/
function filterOrderByColumns(order, columns) {
export function filterOrderByColumns(order, columns) {
if (!Array.isArray(order)) {
return [];
}
@@ -89,7 +89,7 @@ function filterOrderByColumns(order, columns) {
* @param visibility
* @param columns
*/
function filterVisibilityByColumns(visibility, columns) {
export function filterVisibilityByColumns(visibility, columns) {
if (!visibility || typeof visibility !== 'object') {
return {};
}
@@ -107,7 +107,7 @@ function filterVisibilityByColumns(visibility, columns) {
*
* @param col
*/
function getColumnId(col) {
export function getColumnId(col) {
return col?.id ?? col?.accessorKey ?? null;
}
@@ -115,7 +115,7 @@ function getColumnId(col) {
*
* @param columns
*/
function findStretchColumnId(columns) {
export function findStretchColumnId(columns) {
if (!Array.isArray(columns)) {
return null;
}
@@ -153,7 +153,7 @@ function resolveMaybeGetter(func) {
* @param spacerId
* @param stretchAfterId
*/
function withSpacerColumn(columns, enabled, spacerId, stretchAfterId) {
export function withSpacerColumn(columns, enabled, spacerId, stretchAfterId) {
if (!enabled) {
return columns;
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { HSVtoRGB } from '../ui';
describe('HSVtoRGB', () => {
it('converts pure red (h=0, s=1, v=1)', () => {
expect(HSVtoRGB(0, 1, 1)).toBe('#ff0000');
});
it('converts pure green (h=0.333, s=1, v=1)', () => {
const result = HSVtoRGB(1 / 3, 1, 1);
expect(result).toBe('#00ff00');
});
it('converts pure blue (h=0.667, s=1, v=1)', () => {
const result = HSVtoRGB(2 / 3, 1, 1);
expect(result).toBe('#0000ff');
});
it('converts white (s=0, v=1)', () => {
expect(HSVtoRGB(0, 0, 1)).toBe('#ffffff');
});
it('converts black (v=0)', () => {
expect(HSVtoRGB(0, 1, 0)).toBe('#000000');
});
it('converts yellow (h=1/6, s=1, v=1)', () => {
expect(HSVtoRGB(1 / 6, 1, 1)).toBe('#ffff00');
});
it('converts cyan (h=0.5, s=1, v=1)', () => {
expect(HSVtoRGB(0.5, 1, 1)).toBe('#00ffff');
});
it('handles object argument { h, s, v }', () => {
expect(HSVtoRGB({ h: 0, s: 1, v: 1 })).toBe('#ff0000');
});
it('converts a mid-range value', () => {
const result = HSVtoRGB(0, 0, 0.5);
// gray: rgb(128,128,128)
expect(result).toBe('#808080');
});
});

View File

@@ -75,7 +75,7 @@ export function getColumns({
'h-4 w-4',
isActive
? 'text-primary'
: 'text-muted-foreground/0 group-hover/row:text-muted-foreground/40'
: 'text-muted-foreground/0 group-hover/row:text-muted-foreground'
]}
/>
</div>