feat: настраиваемая колонка отображения в дереве справочника
- Добавлено поле data_tables.tree_display_column_index. - Backend /api/directories/:id/rows/tree возвращает displayColumnIndex и формирует label для row-узлов на основе выбранной колонки. - В редакторе справочника добавлен селектор «Колонка для отображения в дереве». - DirectoryRowTree, DirectoryColumnTree, DirectoryTreeSelect, DirectoryTreeMultiSelect используют настроенную колонку. - TableFieldSelect / TableFieldMultiSelect используют treeDisplayColumnIndex справочника как fallback, если в поле формы не задан displayColumn. - Миграция 0057 + запись в _journal.json.
This commit is contained in:
@@ -35,6 +35,7 @@ export default function TableFieldMultiSelect({
|
||||
rows: DataTableRow[];
|
||||
columns: Array<{ name: string }>;
|
||||
treeColumnIndex: number | null;
|
||||
treeDisplayColumnIndex: number | null;
|
||||
};
|
||||
}>({
|
||||
queryKey: ['/api/directories', tableId],
|
||||
@@ -69,7 +70,7 @@ export default function TableFieldMultiSelect({
|
||||
|
||||
let displayColumnIndex = table?.columns?.findIndex((c) => c.name === displayColumn);
|
||||
if (displayColumnIndex === undefined || displayColumnIndex < 0) {
|
||||
displayColumnIndex = 0;
|
||||
displayColumnIndex = table?.treeDisplayColumnIndex ?? 0;
|
||||
}
|
||||
|
||||
const selectedValues = useMemo(() => {
|
||||
@@ -166,7 +167,6 @@ export default function TableFieldMultiSelect({
|
||||
{hasTree ? (
|
||||
<DirectoryTreeMultiSelect
|
||||
nodes={nodes}
|
||||
displayColumnIndex={displayColumnIndex!}
|
||||
selected={selectedSet}
|
||||
onToggle={toggle}
|
||||
searchTerm={searchTerm}
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function TableFieldSelect({
|
||||
rows: DataTableRow[];
|
||||
columns: Array<{ name: string }>;
|
||||
treeColumnIndex: number | null;
|
||||
treeDisplayColumnIndex: number | null;
|
||||
};
|
||||
}>({
|
||||
queryKey: ['/api/directories', tableId],
|
||||
@@ -67,7 +68,7 @@ export default function TableFieldSelect({
|
||||
|
||||
let displayColumnIndex = table?.columns?.findIndex((c) => c.name === displayColumn);
|
||||
if (displayColumnIndex === undefined || displayColumnIndex < 0) {
|
||||
displayColumnIndex = 0;
|
||||
displayColumnIndex = table?.treeDisplayColumnIndex ?? 0;
|
||||
}
|
||||
|
||||
const sortedRows = useMemo(() => {
|
||||
@@ -151,7 +152,6 @@ export default function TableFieldSelect({
|
||||
{hasTree ? (
|
||||
<DirectoryTreeSelect
|
||||
nodes={nodes}
|
||||
displayColumnIndex={displayColumnIndex!}
|
||||
value={value}
|
||||
onChange={handleSelect}
|
||||
searchTerm={searchTerm}
|
||||
|
||||
@@ -107,7 +107,8 @@ function getVisibleItems(tree: DirectoryRowTreeItem[], expanded: Set<number>): D
|
||||
|
||||
function filterTree(
|
||||
tree: DirectoryRowTreeItem[],
|
||||
query: string
|
||||
query: string,
|
||||
labelColumnIndex = 0
|
||||
): { tree: DirectoryRowTreeItem[]; expanded: Set<number> } {
|
||||
if (!query.trim()) return { tree, expanded: new Set<number>() };
|
||||
const q = query.toLowerCase();
|
||||
@@ -115,7 +116,7 @@ function filterTree(
|
||||
function walk(nodes: DirectoryRowTreeItem[]): DirectoryRowTreeItem[] {
|
||||
const visible: DirectoryRowTreeItem[] = [];
|
||||
for (const node of nodes) {
|
||||
const label = String(node.values[0] ?? '').toLowerCase();
|
||||
const label = getRowLabel(node, labelColumnIndex).toLowerCase();
|
||||
const children = walk(node.children);
|
||||
const childMatch = children.length > 0;
|
||||
if (label.includes(q) || childMatch) {
|
||||
@@ -298,7 +299,7 @@ export function DirectoryRowTree({
|
||||
searchQuery,
|
||||
}: DirectoryRowTreeProps & { searchQuery?: string }) {
|
||||
const tree = useMemo(() => buildTree(rows), [rows]);
|
||||
const filtered = useMemo(() => filterTree(tree, searchQuery || ''), [tree, searchQuery]);
|
||||
const filtered = useMemo(() => filterTree(tree, searchQuery || '', labelColumnIndex), [tree, searchQuery, labelColumnIndex]);
|
||||
const effectiveExpanded = useMemo(
|
||||
() => (searchQuery?.trim() ? filtered.expanded : expanded),
|
||||
[searchQuery, filtered.expanded, expanded]
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { DataTableRow } from '@shared/schema';
|
||||
|
||||
export type DirectoryTreeNode =
|
||||
| { type: 'group'; id: string; label: string; level: number; children: DirectoryTreeNode[] }
|
||||
| { type: 'row'; id: number; level: number; row: DataTableRow };
|
||||
| { type: 'row'; id: number; label: string; level: number; row: DataTableRow };
|
||||
|
||||
function assignLevels(nodes: DirectoryTreeNode[], level = 0): DirectoryTreeNode[] {
|
||||
return nodes.map((node) => {
|
||||
@@ -19,7 +19,6 @@ function assignLevels(nodes: DirectoryTreeNode[], level = 0): DirectoryTreeNode[
|
||||
|
||||
interface DirectoryTreeMultiSelectProps {
|
||||
nodes: DirectoryTreeNode[];
|
||||
displayColumnIndex: number;
|
||||
selected: Set<string>;
|
||||
onToggle: (rowId: string) => void;
|
||||
searchTerm?: string;
|
||||
@@ -27,8 +26,7 @@ interface DirectoryTreeMultiSelectProps {
|
||||
|
||||
function filterNodes(
|
||||
nodes: DirectoryTreeNode[],
|
||||
query: string,
|
||||
displayColumnIndex: number
|
||||
query: string
|
||||
): { nodes: DirectoryTreeNode[]; expanded: Set<string> } {
|
||||
if (!query.trim()) return { nodes, expanded: new Set<string>() };
|
||||
const q = query.toLowerCase();
|
||||
@@ -38,7 +36,7 @@ function filterNodes(
|
||||
const visible: DirectoryTreeNode[] = [];
|
||||
for (const node of items) {
|
||||
if (node.type === 'row') {
|
||||
const label = String(node.row.values[displayColumnIndex] ?? node.row.values[0] ?? '').toLowerCase();
|
||||
const label = node.label.toLowerCase();
|
||||
if (label.includes(q)) visible.push(node);
|
||||
} else {
|
||||
const children = walk(node.children);
|
||||
@@ -56,21 +54,14 @@ function filterNodes(
|
||||
return { nodes: walk(nodes), expanded };
|
||||
}
|
||||
|
||||
function getRowLabel(row: DataTableRow, displayColumnIndex: number): string {
|
||||
const values = Array.isArray(row.values) ? row.values : [];
|
||||
return String(values[displayColumnIndex] ?? values[0] ?? '') || '(без названия)';
|
||||
}
|
||||
|
||||
function TreeNode({
|
||||
node,
|
||||
displayColumnIndex,
|
||||
selected,
|
||||
onToggle,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
}: {
|
||||
node: DirectoryTreeNode;
|
||||
displayColumnIndex: number;
|
||||
selected: Set<string>;
|
||||
onToggle: (rowId: string) => void;
|
||||
expanded: Set<string>;
|
||||
@@ -87,7 +78,7 @@ function TreeNode({
|
||||
<Checkbox checked={selected.has(rowId)} />
|
||||
<FileText className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
<span className={cn('flex-1 truncate', selected.has(rowId) && 'font-medium')}>
|
||||
{getRowLabel(node.row, displayColumnIndex)}
|
||||
{node.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -117,7 +108,6 @@ function TreeNode({
|
||||
<TreeNode
|
||||
key={child.type === 'group' ? child.id : child.id}
|
||||
node={child}
|
||||
displayColumnIndex={displayColumnIndex}
|
||||
selected={selected}
|
||||
onToggle={onToggle}
|
||||
expanded={expanded}
|
||||
@@ -132,15 +122,14 @@ function TreeNode({
|
||||
|
||||
export function DirectoryTreeMultiSelect({
|
||||
nodes,
|
||||
displayColumnIndex,
|
||||
selected,
|
||||
onToggle,
|
||||
searchTerm = '',
|
||||
}: DirectoryTreeMultiSelectProps) {
|
||||
const nodesWithLevels = useMemo(() => assignLevels(nodes), [nodes]);
|
||||
const filtered = useMemo(
|
||||
() => filterNodes(nodesWithLevels, searchTerm, displayColumnIndex),
|
||||
[nodesWithLevels, searchTerm, displayColumnIndex]
|
||||
() => filterNodes(nodesWithLevels, searchTerm),
|
||||
[nodesWithLevels, searchTerm]
|
||||
);
|
||||
const [manualExpanded, setManualExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -168,7 +157,6 @@ export function DirectoryTreeMultiSelect({
|
||||
<TreeNode
|
||||
key={node.type === 'group' ? node.id : node.id}
|
||||
node={node}
|
||||
displayColumnIndex={displayColumnIndex}
|
||||
selected={selected}
|
||||
onToggle={onToggle}
|
||||
expanded={effectiveExpanded}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { DataTableRow } from '@shared/schema';
|
||||
|
||||
export type DirectoryTreeNode =
|
||||
| { type: 'group'; id: string; label: string; level: number; children: DirectoryTreeNode[] }
|
||||
| { type: 'row'; id: number; level: number; row: DataTableRow };
|
||||
| { type: 'row'; id: number; label: string; level: number; row: DataTableRow };
|
||||
|
||||
function assignLevels(nodes: DirectoryTreeNode[], level = 0): DirectoryTreeNode[] {
|
||||
return nodes.map((node) => {
|
||||
@@ -18,7 +18,6 @@ function assignLevels(nodes: DirectoryTreeNode[], level = 0): DirectoryTreeNode[
|
||||
|
||||
interface DirectoryTreeSelectProps {
|
||||
nodes: DirectoryTreeNode[];
|
||||
displayColumnIndex: number;
|
||||
value?: string | number | null;
|
||||
onChange: (rowId: string) => void;
|
||||
searchTerm?: string;
|
||||
@@ -26,8 +25,7 @@ interface DirectoryTreeSelectProps {
|
||||
|
||||
function filterNodes(
|
||||
nodes: DirectoryTreeNode[],
|
||||
query: string,
|
||||
displayColumnIndex: number
|
||||
query: string
|
||||
): { nodes: DirectoryTreeNode[]; expanded: Set<string> } {
|
||||
if (!query.trim()) return { nodes, expanded: new Set<string>() };
|
||||
const q = query.toLowerCase();
|
||||
@@ -37,7 +35,7 @@ function filterNodes(
|
||||
const visible: DirectoryTreeNode[] = [];
|
||||
for (const node of items) {
|
||||
if (node.type === 'row') {
|
||||
const label = String(node.row.values[displayColumnIndex] ?? node.row.values[0] ?? '').toLowerCase();
|
||||
const label = node.label.toLowerCase();
|
||||
if (label.includes(q)) visible.push(node);
|
||||
} else {
|
||||
const children = walk(node.children);
|
||||
@@ -55,21 +53,14 @@ function filterNodes(
|
||||
return { nodes: walk(nodes), expanded };
|
||||
}
|
||||
|
||||
function getRowLabel(row: DataTableRow, displayColumnIndex: number): string {
|
||||
const values = Array.isArray(row.values) ? row.values : [];
|
||||
return String(values[displayColumnIndex] ?? values[0] ?? '') || '(без названия)';
|
||||
}
|
||||
|
||||
function TreeNode({
|
||||
node,
|
||||
displayColumnIndex,
|
||||
value,
|
||||
onChange,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
node: DirectoryTreeNode;
|
||||
displayColumnIndex: number;
|
||||
value?: string | number | null;
|
||||
onChange: (rowId: string) => void;
|
||||
expanded: Set<string>;
|
||||
@@ -89,7 +80,7 @@ function TreeNode({
|
||||
onClick={() => onChange(rowId)}
|
||||
>
|
||||
<FileText className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{getRowLabel(node.row, displayColumnIndex)}</span>
|
||||
<span className="flex-1 truncate">{node.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -116,7 +107,6 @@ function TreeNode({
|
||||
<TreeNode
|
||||
key={child.type === 'group' ? child.id : child.id}
|
||||
node={child}
|
||||
displayColumnIndex={displayColumnIndex}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
expanded={expanded}
|
||||
@@ -131,15 +121,14 @@ function TreeNode({
|
||||
|
||||
export function DirectoryTreeSelect({
|
||||
nodes,
|
||||
displayColumnIndex,
|
||||
value,
|
||||
onChange,
|
||||
searchTerm = '',
|
||||
}: DirectoryTreeSelectProps) {
|
||||
const nodesWithLevels = useMemo(() => assignLevels(nodes), [nodes]);
|
||||
const filtered = useMemo(
|
||||
() => filterNodes(nodesWithLevels, searchTerm, displayColumnIndex),
|
||||
[nodesWithLevels, searchTerm, displayColumnIndex]
|
||||
() => filterNodes(nodesWithLevels, searchTerm),
|
||||
[nodesWithLevels, searchTerm]
|
||||
);
|
||||
const [manualExpanded, setManualExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -176,7 +165,6 @@ export function DirectoryTreeSelect({
|
||||
<TreeNode
|
||||
key={node.type === 'group' ? node.id : node.id}
|
||||
node={node}
|
||||
displayColumnIndex={displayColumnIndex}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
expanded={effectiveExpanded}
|
||||
|
||||
@@ -87,6 +87,7 @@ export default function TableEditor() {
|
||||
const [tableDescription, setTableDescription] = useState('');
|
||||
const [tableColumns, setTableColumns] = useState<TableColumn[]>([]);
|
||||
const [treeColumnIndex, setTreeColumnIndex] = useState<number | null>(null);
|
||||
const [treeDisplayColumnIndex, setTreeDisplayColumnIndex] = useState<number | null>(null);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importPreview, setImportPreview] = useState<{
|
||||
added: { values: string[] }[];
|
||||
@@ -175,6 +176,7 @@ export default function TableEditor() {
|
||||
setTableDescription(data.table.description || '');
|
||||
setTableColumns(data.table.columns as TableColumn[]);
|
||||
setTreeColumnIndex(data.table.treeColumnIndex ?? null);
|
||||
setTreeDisplayColumnIndex(data.table.treeDisplayColumnIndex ?? null);
|
||||
setNewRowValues(new Array(data.table.columns.length).fill(''));
|
||||
}
|
||||
}, [data]);
|
||||
@@ -186,7 +188,7 @@ export default function TableEditor() {
|
||||
}, [rowEditorOpen, rowEditorInitialValues]);
|
||||
|
||||
const updateTableMutation = useMutation({
|
||||
mutationFn: async (updates: { name?: string; description?: string; columns?: TableColumn[]; treeColumnIndex?: number | null }) => {
|
||||
mutationFn: async (updates: { name?: string; description?: string; columns?: TableColumn[]; treeColumnIndex?: number | null; treeDisplayColumnIndex?: number | null }) => {
|
||||
return apiRequest('PATCH', `/api/directories/${tableId}`, updates);
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -723,7 +725,7 @@ export default function TableEditor() {
|
||||
<DirectoryColumnTree
|
||||
rows={displayRows}
|
||||
treeColumnIndex={treeColumnIndex}
|
||||
displayColumnIndex={0}
|
||||
displayColumnIndex={treeDisplayColumnIndex ?? 0}
|
||||
selectedRowId={selectedRowId}
|
||||
onSelect={handleTreeSelect}
|
||||
onEdit={handleTreeSelect}
|
||||
@@ -741,6 +743,7 @@ export default function TableEditor() {
|
||||
>
|
||||
<DirectoryRowTree
|
||||
rows={displayRows}
|
||||
labelColumnIndex={treeDisplayColumnIndex ?? 0}
|
||||
selectedRowId={selectedRowId}
|
||||
expanded={expanded}
|
||||
onSelect={handleTreeSelect}
|
||||
@@ -755,7 +758,7 @@ export default function TableEditor() {
|
||||
{activeRow ? (
|
||||
<div className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm bg-accent text-accent-foreground shadow-sm border">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>{activeRow.values[0] || '—'}</span>
|
||||
<span>{activeRow.values[treeDisplayColumnIndex ?? 0] || activeRow.values[0] || '—'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
@@ -1195,13 +1198,34 @@ export default function TableEditor() {
|
||||
Строки будут сгруппированы по уникальным значениям выбранной колонки.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-table-tree-display-column">Колонка для отображения в дереве</Label>
|
||||
<Select
|
||||
value={treeDisplayColumnIndex === null ? '0' : String(treeDisplayColumnIndex)}
|
||||
onValueChange={(val) => setTreeDisplayColumnIndex(Number(val))}
|
||||
>
|
||||
<SelectTrigger id="edit-table-tree-display-column" className="mt-2">
|
||||
<SelectValue placeholder="Первая колонка" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col, idx) => (
|
||||
<SelectItem key={idx} value={String(idx)}>
|
||||
{col.name || `Колонка ${idx + 1}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Значение этой колонки будет показано как название узла в дереве.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSettingsOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => updateTableMutation.mutate({ name: tableName, description: tableDescription, columns: tableColumns, treeColumnIndex })}
|
||||
onClick={() => updateTableMutation.mutate({ name: tableName, description: tableDescription, columns: tableColumns, treeColumnIndex, treeDisplayColumnIndex })}
|
||||
disabled={updateTableMutation.isPending}
|
||||
data-testid="button-save-settings"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user