mirror of
https://github.com/chartdb/chartdb.git
synced 2025-11-02 21:13:23 +00:00
Compare commits
3 Commits
jf/edit-cl
...
jf/add_edi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7382626b92 | ||
|
|
6f6b59c74f | ||
|
|
4f1a378762 |
@@ -11,14 +11,12 @@ export interface ColorPickerProps {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
disabled?: boolean;
|
||||
popoverOnMouseDown?: (e: React.MouseEvent) => void;
|
||||
popoverOnClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const ColorPicker = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverTrigger>,
|
||||
ColorPickerProps
|
||||
>(({ color, onChange, disabled, popoverOnMouseDown, popoverOnClick }, ref) => {
|
||||
>(({ color, onChange, disabled }, ref) => {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
@@ -39,11 +37,7 @@ export const ColorPicker = React.forwardRef<
|
||||
}}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-fit"
|
||||
onMouseDown={popoverOnMouseDown}
|
||||
onClick={popoverOnClick}
|
||||
>
|
||||
<PopoverContent className="w-fit">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{colorOptions.map((option) => (
|
||||
<div
|
||||
|
||||
@@ -56,8 +56,6 @@ export interface SelectBoxProps {
|
||||
popoverClassName?: string;
|
||||
readonly?: boolean;
|
||||
footerButtons?: React.ReactNode;
|
||||
commandOnMouseDown?: (e: React.MouseEvent) => void;
|
||||
commandOnClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
@@ -85,8 +83,6 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
popoverClassName,
|
||||
readonly,
|
||||
footerButtons,
|
||||
commandOnMouseDown,
|
||||
commandOnClick,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -247,8 +243,6 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
matches?.map((match) => match?.toString())
|
||||
)
|
||||
}
|
||||
onMouseDown={commandOnMouseDown}
|
||||
onClick={commandOnClick}
|
||||
>
|
||||
{multiple && (
|
||||
<div
|
||||
@@ -294,15 +288,7 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
</CommandItem>
|
||||
);
|
||||
},
|
||||
[
|
||||
value,
|
||||
multiple,
|
||||
searchTerm,
|
||||
handleSelect,
|
||||
optionSuffix,
|
||||
commandOnClick,
|
||||
commandOnMouseDown,
|
||||
]
|
||||
[value, multiple, searchTerm, handleSelect, optionSuffix]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -380,8 +366,6 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
popoverClassName
|
||||
)}
|
||||
align="center"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Command
|
||||
filter={(value, search, keywords) => {
|
||||
|
||||
@@ -14,16 +14,6 @@ export interface CanvasContext {
|
||||
overlapGraph: Graph<string>;
|
||||
setShowFilter: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
showFilter: boolean;
|
||||
editTableModeTable: {
|
||||
tableId: string;
|
||||
fieldId?: string;
|
||||
} | null;
|
||||
setEditTableModeTable: React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
tableId: string;
|
||||
fieldId?: string;
|
||||
} | null>
|
||||
>;
|
||||
}
|
||||
|
||||
export const canvasContext = createContext<CanvasContext>({
|
||||
@@ -33,6 +23,4 @@ export const canvasContext = createContext<CanvasContext>({
|
||||
overlapGraph: createGraph(),
|
||||
setShowFilter: emptyFn,
|
||||
showFilter: false,
|
||||
editTableModeTable: null,
|
||||
setEditTableModeTable: emptyFn,
|
||||
});
|
||||
|
||||
@@ -33,10 +33,6 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
|
||||
const { fitView } = useReactFlow();
|
||||
const [overlapGraph, setOverlapGraph] =
|
||||
useState<Graph<string>>(createGraph());
|
||||
const [editTableModeTable, setEditTableModeTable] = useState<{
|
||||
tableId: string;
|
||||
fieldId?: string;
|
||||
} | null>(null);
|
||||
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const diagramIdActiveFilterRef = useRef<string>();
|
||||
@@ -131,8 +127,6 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
|
||||
overlapGraph,
|
||||
setShowFilter,
|
||||
showFilter,
|
||||
editTableModeTable,
|
||||
setEditTableModeTable,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useEffect, useCallback, type RefObject } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook that handles click outside detection with capture phase
|
||||
* to work properly with React Flow canvas and other event-stopping elements
|
||||
*/
|
||||
export function useClickOutside(
|
||||
ref: RefObject<HTMLElement>,
|
||||
handler: () => void,
|
||||
isActive = true
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
// Use capture phase to catch events before React Flow or other libraries can stop them
|
||||
document.addEventListener('mousedown', handleClickOutside, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside, true);
|
||||
};
|
||||
}, [ref, handler, isActive]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized version of useClickOutside for edit mode inputs
|
||||
* Adds a small delay to prevent race conditions with blur events
|
||||
*/
|
||||
export function useEditClickOutside(
|
||||
inputRef: RefObject<HTMLElement>,
|
||||
editMode: boolean,
|
||||
onSave: () => void,
|
||||
delay = 100
|
||||
) {
|
||||
const handleClickOutside = useCallback(() => {
|
||||
if (editMode) {
|
||||
// Small delay to ensure any pending state updates are processed
|
||||
setTimeout(() => {
|
||||
onSave();
|
||||
}, delay);
|
||||
}
|
||||
}, [editMode, onSave, delay]);
|
||||
|
||||
useClickOutside(inputRef, handleClickOutside, editMode);
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
import { useCallback, useMemo, useState, useEffect } from 'react';
|
||||
import { useChartDB } from './use-chartdb';
|
||||
import { useDebounce } from './use-debounce-v2';
|
||||
import type { DBField, DBTable } from '@/lib/domain';
|
||||
import type {
|
||||
SelectBoxOption,
|
||||
SelectBoxProps,
|
||||
} from '@/components/select-box/select-box';
|
||||
import {
|
||||
dataTypeDataToDataType,
|
||||
sortedDataTypeMap,
|
||||
} from '@/lib/data/data-types/data-types';
|
||||
import { generateDBFieldSuffix } from '@/lib/domain/db-field';
|
||||
import type { DataTypeData } from '@/lib/data/data-types/data-types';
|
||||
|
||||
const generateFieldRegexPatterns = (
|
||||
dataType: DataTypeData
|
||||
): {
|
||||
regex?: string;
|
||||
extractRegex?: RegExp;
|
||||
} => {
|
||||
if (!dataType.fieldAttributes) {
|
||||
return { regex: undefined, extractRegex: undefined };
|
||||
}
|
||||
|
||||
const typeName = dataType.name;
|
||||
const fieldAttributes = dataType.fieldAttributes;
|
||||
|
||||
if (fieldAttributes.hasCharMaxLength) {
|
||||
if (fieldAttributes.hasCharMaxLengthOption) {
|
||||
return {
|
||||
regex: `^${typeName}\\((\\d+|[mM][aA][xX])\\)$`,
|
||||
extractRegex: /\((\d+|max)\)/i,
|
||||
};
|
||||
}
|
||||
return {
|
||||
regex: `^${typeName}\\(\\d+\\)$`,
|
||||
extractRegex: /\((\d+)\)/,
|
||||
};
|
||||
}
|
||||
|
||||
if (fieldAttributes.precision && fieldAttributes.scale) {
|
||||
return {
|
||||
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*(?:,\\s*\\d+\\s*)?\\)$`,
|
||||
extractRegex: new RegExp(
|
||||
`${typeName}\\s*\\(\\s*(\\d+)\\s*(?:,\\s*(\\d+)\\s*)?\\)`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (fieldAttributes.precision) {
|
||||
return {
|
||||
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*\\)$`,
|
||||
extractRegex: /\((\d+)\)/,
|
||||
};
|
||||
}
|
||||
|
||||
return { regex: undefined, extractRegex: undefined };
|
||||
};
|
||||
|
||||
export const useUpdateTableField = (
|
||||
table: DBTable,
|
||||
field: DBField,
|
||||
customUpdateField?: (attrs: Partial<DBField>) => void
|
||||
) => {
|
||||
const {
|
||||
databaseType,
|
||||
customTypes,
|
||||
updateField: chartDBUpdateField,
|
||||
removeField: chartDBRemoveField,
|
||||
} = useChartDB();
|
||||
|
||||
// Local state for responsive UI
|
||||
const [localFieldName, setLocalFieldName] = useState(field.name);
|
||||
const [localNullable, setLocalNullable] = useState(field.nullable);
|
||||
const [localPrimaryKey, setLocalPrimaryKey] = useState(field.primaryKey);
|
||||
|
||||
// Update local state when field properties change externally
|
||||
useEffect(() => {
|
||||
setLocalFieldName(field.name);
|
||||
setLocalNullable(field.nullable);
|
||||
setLocalPrimaryKey(field.primaryKey);
|
||||
}, [field.name, field.nullable, field.primaryKey]);
|
||||
|
||||
// Use custom updateField if provided, otherwise use the chartDB one
|
||||
const updateField = useMemo(
|
||||
() =>
|
||||
customUpdateField
|
||||
? (
|
||||
_tableId: string,
|
||||
_fieldId: string,
|
||||
attrs: Partial<DBField>
|
||||
) => customUpdateField(attrs)
|
||||
: chartDBUpdateField,
|
||||
[customUpdateField, chartDBUpdateField]
|
||||
);
|
||||
|
||||
// Calculate primary key fields for validation
|
||||
const primaryKeyFields = useMemo(() => {
|
||||
return table.fields.filter((f) => f.primaryKey);
|
||||
}, [table.fields]);
|
||||
|
||||
const primaryKeyCount = useMemo(
|
||||
() => primaryKeyFields.length,
|
||||
[primaryKeyFields.length]
|
||||
);
|
||||
|
||||
// Generate data type options for select box
|
||||
const dataFieldOptions = useMemo(() => {
|
||||
const standardTypes: SelectBoxOption[] = sortedDataTypeMap[
|
||||
databaseType
|
||||
].map((type) => {
|
||||
const regexPatterns = generateFieldRegexPatterns(type);
|
||||
|
||||
return {
|
||||
label: type.name,
|
||||
value: type.id,
|
||||
regex: regexPatterns.regex,
|
||||
extractRegex: regexPatterns.extractRegex,
|
||||
group: customTypes?.length ? 'Standard Types' : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
if (!customTypes?.length) {
|
||||
return standardTypes;
|
||||
}
|
||||
|
||||
// Add custom types as options
|
||||
const customTypeOptions: SelectBoxOption[] = customTypes.map(
|
||||
(type) => ({
|
||||
label: type.name,
|
||||
value: type.name,
|
||||
description:
|
||||
type.kind === 'enum' ? `${type.values?.join(' | ')}` : '',
|
||||
group: 'Custom Types',
|
||||
})
|
||||
);
|
||||
|
||||
return [...standardTypes, ...customTypeOptions];
|
||||
}, [databaseType, customTypes]);
|
||||
|
||||
// Handle data type change
|
||||
const handleDataTypeChange = useCallback<
|
||||
NonNullable<SelectBoxProps['onChange']>
|
||||
>(
|
||||
(value, regexMatches) => {
|
||||
const dataType = sortedDataTypeMap[databaseType].find(
|
||||
(v) => v.id === value
|
||||
) ?? {
|
||||
id: value as string,
|
||||
name: value as string,
|
||||
};
|
||||
|
||||
let characterMaximumLength: string | undefined = undefined;
|
||||
let precision: number | undefined = undefined;
|
||||
let scale: number | undefined = undefined;
|
||||
|
||||
if (regexMatches?.length) {
|
||||
if (dataType?.fieldAttributes?.hasCharMaxLength) {
|
||||
characterMaximumLength = regexMatches[1]?.toLowerCase();
|
||||
} else if (
|
||||
dataType?.fieldAttributes?.precision &&
|
||||
dataType?.fieldAttributes?.scale
|
||||
) {
|
||||
precision = parseInt(regexMatches[1]);
|
||||
scale = regexMatches[2]
|
||||
? parseInt(regexMatches[2])
|
||||
: undefined;
|
||||
} else if (dataType?.fieldAttributes?.precision) {
|
||||
precision = parseInt(regexMatches[1]);
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
dataType?.fieldAttributes?.hasCharMaxLength &&
|
||||
field.characterMaximumLength
|
||||
) {
|
||||
characterMaximumLength = field.characterMaximumLength;
|
||||
}
|
||||
|
||||
if (dataType?.fieldAttributes?.precision && field.precision) {
|
||||
precision = field.precision;
|
||||
}
|
||||
|
||||
if (dataType?.fieldAttributes?.scale && field.scale) {
|
||||
scale = field.scale;
|
||||
}
|
||||
}
|
||||
|
||||
updateField(table.id, field.id, {
|
||||
characterMaximumLength,
|
||||
precision,
|
||||
scale,
|
||||
increment: undefined,
|
||||
default: undefined,
|
||||
type: dataTypeDataToDataType(
|
||||
dataType ?? {
|
||||
id: value as string,
|
||||
name: value as string,
|
||||
}
|
||||
),
|
||||
});
|
||||
},
|
||||
[
|
||||
updateField,
|
||||
databaseType,
|
||||
field.characterMaximumLength,
|
||||
field.precision,
|
||||
field.scale,
|
||||
field.id,
|
||||
table.id,
|
||||
]
|
||||
);
|
||||
|
||||
// Debounced update for field name
|
||||
const debouncedNameUpdate = useDebounce(
|
||||
useCallback(
|
||||
(value: string) => {
|
||||
if (value.trim() !== field.name) {
|
||||
updateField(table.id, field.id, { name: value });
|
||||
}
|
||||
},
|
||||
[updateField, table.id, field.id, field.name]
|
||||
),
|
||||
300 // 300ms debounce for text input
|
||||
);
|
||||
|
||||
// Debounced update for nullable toggle
|
||||
const debouncedNullableUpdate = useDebounce(
|
||||
useCallback(
|
||||
(value: boolean) => {
|
||||
updateField(table.id, field.id, { nullable: value });
|
||||
},
|
||||
[updateField, table.id, field.id]
|
||||
),
|
||||
100 // 100ms debounce for toggle
|
||||
);
|
||||
|
||||
// Debounced update for primary key toggle
|
||||
const debouncedPrimaryKeyUpdate = useDebounce(
|
||||
useCallback(
|
||||
(value: boolean, primaryKeyCount: number) => {
|
||||
if (value) {
|
||||
// When setting as primary key
|
||||
const updates: Partial<DBField> = {
|
||||
primaryKey: true,
|
||||
};
|
||||
// Only auto-set unique if this will be the only primary key
|
||||
if (primaryKeyCount === 0) {
|
||||
updates.unique = true;
|
||||
}
|
||||
updateField(table.id, field.id, updates);
|
||||
} else {
|
||||
// When removing primary key
|
||||
updateField(table.id, field.id, {
|
||||
primaryKey: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[updateField, table.id, field.id]
|
||||
),
|
||||
100 // 100ms debounce for toggle
|
||||
);
|
||||
|
||||
// Handle primary key toggle with optimistic update
|
||||
const handlePrimaryKeyToggle = useCallback(
|
||||
(value: boolean) => {
|
||||
setLocalPrimaryKey(value);
|
||||
debouncedPrimaryKeyUpdate(value, primaryKeyCount);
|
||||
},
|
||||
[primaryKeyCount, debouncedPrimaryKeyUpdate]
|
||||
);
|
||||
|
||||
// Handle nullable toggle with optimistic update
|
||||
const handleNullableToggle = useCallback(
|
||||
(value: boolean) => {
|
||||
setLocalNullable(value);
|
||||
debouncedNullableUpdate(value);
|
||||
},
|
||||
[debouncedNullableUpdate]
|
||||
);
|
||||
|
||||
// Handle name change with optimistic update
|
||||
const handleNameChange = useCallback(
|
||||
(value: string) => {
|
||||
setLocalFieldName(value);
|
||||
debouncedNameUpdate(value);
|
||||
},
|
||||
[debouncedNameUpdate]
|
||||
);
|
||||
|
||||
// Utility function to generate field suffix for display
|
||||
const generateFieldSuffix = useCallback(
|
||||
(typeId?: string) => {
|
||||
return generateDBFieldSuffix(field, {
|
||||
databaseType,
|
||||
forceExtended: true,
|
||||
typeId,
|
||||
});
|
||||
},
|
||||
[field, databaseType]
|
||||
);
|
||||
|
||||
const removeField = useCallback(() => {
|
||||
chartDBRemoveField(table.id, field.id);
|
||||
}, [chartDBRemoveField, table.id, field.id]);
|
||||
|
||||
return {
|
||||
dataFieldOptions,
|
||||
handleDataTypeChange,
|
||||
handlePrimaryKeyToggle,
|
||||
handleNullableToggle,
|
||||
handleNameChange,
|
||||
generateFieldSuffix,
|
||||
primaryKeyCount,
|
||||
fieldName: localFieldName,
|
||||
nullable: localNullable,
|
||||
primaryKey: localPrimaryKey,
|
||||
removeField,
|
||||
};
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import { useChartDB } from './use-chartdb';
|
||||
import { useDebounce } from './use-debounce-v2';
|
||||
import type { DBTable } from '@/lib/domain';
|
||||
|
||||
// Hook for updating table properties with debouncing for performance
|
||||
export const useUpdateTable = (table: DBTable) => {
|
||||
const { updateTable: chartDBUpdateTable } = useChartDB();
|
||||
const [localTableName, setLocalTableName] = useState(table.name);
|
||||
|
||||
// Debounced update function
|
||||
const debouncedUpdate = useDebounce(
|
||||
useCallback(
|
||||
(value: string) => {
|
||||
if (value.trim() && value.trim() !== table.name) {
|
||||
chartDBUpdateTable(table.id, { name: value.trim() });
|
||||
}
|
||||
},
|
||||
[chartDBUpdateTable, table.id, table.name]
|
||||
),
|
||||
1000 // 1000ms debounce
|
||||
);
|
||||
|
||||
// Update local state immediately for responsive UI
|
||||
const handleTableNameChange = useCallback(
|
||||
(value: string) => {
|
||||
setLocalTableName(value);
|
||||
debouncedUpdate(value);
|
||||
},
|
||||
[debouncedUpdate]
|
||||
);
|
||||
|
||||
// Update local state when table name changes externally
|
||||
useEffect(() => {
|
||||
setLocalTableName(table.name);
|
||||
}, [table.name]);
|
||||
|
||||
return {
|
||||
tableName: localTableName,
|
||||
handleTableNameChange,
|
||||
};
|
||||
};
|
||||
@@ -18,7 +18,4 @@
|
||||
|
||||
.marker-definitions {
|
||||
}
|
||||
|
||||
.nodrag {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/context-menu/context-menu';
|
||||
import { useBreakpoint } from '@/hooks/use-breakpoint';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import type { Area } from '@/lib/domain/area';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
export interface AreaNodeContextMenuProps {
|
||||
area: Area;
|
||||
onEditName?: () => void;
|
||||
}
|
||||
|
||||
export const AreaNodeContextMenu: React.FC<
|
||||
React.PropsWithChildren<AreaNodeContextMenuProps>
|
||||
> = ({ children, area, onEditName }) => {
|
||||
const { removeArea, readonly } = useChartDB();
|
||||
const { isMd: isDesktop } = useBreakpoint('md');
|
||||
|
||||
const removeAreaHandler = useCallback(() => {
|
||||
removeArea(area.id);
|
||||
}, [removeArea, area.id]);
|
||||
|
||||
if (!isDesktop || readonly) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{onEditName && (
|
||||
<ContextMenuItem
|
||||
onClick={onEditName}
|
||||
className="flex justify-between gap-3"
|
||||
>
|
||||
<span>Edit Area Name</span>
|
||||
<Pencil className="size-3.5" />
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
onClick={removeAreaHandler}
|
||||
className="flex justify-between gap-3"
|
||||
>
|
||||
<span>Delete Area</span>
|
||||
<Trash2 className="size-3.5 text-red-700" />
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import type { NodeProps, Node } from '@xyflow/react';
|
||||
import { NodeResizer } from '@xyflow/react';
|
||||
import type { Area } from '@/lib/domain/area';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import { Input } from '@/components/input/input';
|
||||
import { useEditClickOutside } from '@/hooks/use-click-outside';
|
||||
import { useKeyPressEvent } from 'react-use';
|
||||
import { useClickAway, useKeyPressEvent } from 'react-use';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -13,10 +12,9 @@ import {
|
||||
} from '@/components/tooltip/tooltip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check, GripVertical, Pencil } from 'lucide-react';
|
||||
import { Check, GripVertical } from 'lucide-react';
|
||||
import { Button } from '@/components/button/button';
|
||||
import { useLayout } from '@/hooks/use-layout';
|
||||
import { AreaNodeContextMenu } from './area-node-context-menu';
|
||||
|
||||
export type AreaNodeType = Node<
|
||||
{
|
||||
@@ -37,11 +35,12 @@ export const AreaNode: React.FC<NodeProps<AreaNodeType>> = React.memo(
|
||||
const focused = !!selected && !dragging;
|
||||
|
||||
const editAreaName = useCallback(() => {
|
||||
if (!editMode) return;
|
||||
if (areaName.trim()) {
|
||||
updateArea(area.id, { name: areaName.trim() });
|
||||
}
|
||||
setEditMode(false);
|
||||
}, [areaName, area.id, updateArea]);
|
||||
}, [areaName, area.id, updateArea, editMode]);
|
||||
|
||||
const abortEdit = useCallback(() => {
|
||||
setEditMode(false);
|
||||
@@ -53,119 +52,89 @@ export const AreaNode: React.FC<NodeProps<AreaNodeType>> = React.memo(
|
||||
openAreaFromSidebar(area.id);
|
||||
}, [selectSidebarSection, openAreaFromSidebar, area.id]);
|
||||
|
||||
// Handle click outside to save and exit edit mode
|
||||
useEditClickOutside(inputRef, editMode, editAreaName);
|
||||
useClickAway(inputRef, editAreaName);
|
||||
useKeyPressEvent('Enter', editAreaName);
|
||||
useKeyPressEvent('Escape', abortEdit);
|
||||
|
||||
const enterEditMode = useCallback(
|
||||
(e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
setAreaName(area.name);
|
||||
setEditMode(true);
|
||||
},
|
||||
[area.name]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (editMode) {
|
||||
// Small delay to ensure the input is rendered
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [editMode]);
|
||||
const enterEditMode = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditMode(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<AreaNodeContextMenu area={area} onEditName={enterEditMode}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full flex-col rounded-md border-2 shadow-sm',
|
||||
selected ? 'border-pink-600' : 'border-transparent'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: `${area.color}15`,
|
||||
borderColor: selected ? undefined : area.color,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.detail === 2) {
|
||||
openAreaInEditor();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!readonly ? (
|
||||
<NodeResizer
|
||||
isVisible={focused}
|
||||
lineClassName="!border-4 !border-transparent"
|
||||
handleClassName="!h-[10px] !w-[10px] !rounded-full !bg-pink-600"
|
||||
minHeight={100}
|
||||
minWidth={100}
|
||||
/>
|
||||
) : null}
|
||||
<div className="group flex h-8 items-center justify-between rounded-t-md px-2">
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<GripVertical className="size-4 shrink-0 text-slate-700 opacity-60 dark:text-slate-300" />
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full flex-col rounded-md border-2 shadow-sm',
|
||||
selected ? 'border-pink-600' : 'border-transparent'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: `${area.color}15`,
|
||||
borderColor: selected ? undefined : area.color,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.detail === 2) {
|
||||
openAreaInEditor();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!readonly ? (
|
||||
<NodeResizer
|
||||
isVisible={focused}
|
||||
lineClassName="!border-4 !border-transparent"
|
||||
handleClassName="!h-[10px] !w-[10px] !rounded-full !bg-pink-600"
|
||||
minHeight={100}
|
||||
minWidth={100}
|
||||
/>
|
||||
) : null}
|
||||
<div className="group flex h-8 items-center justify-between rounded-t-md px-2">
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<GripVertical className="size-4 shrink-0 text-slate-700 opacity-60 dark:text-slate-300" />
|
||||
|
||||
{editMode && !readonly ? (
|
||||
<div className="flex w-full items-center">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={area.name}
|
||||
value={areaName}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) =>
|
||||
setAreaName(e.target.value)
|
||||
}
|
||||
className="h-6 bg-white/70 focus-visible:ring-0 dark:bg-slate-900/70"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="ml-1 size-6 p-0 hover:bg-white/20"
|
||||
onClick={editAreaName}
|
||||
>
|
||||
<Check className="size-3.5 text-slate-700 dark:text-slate-300" />
|
||||
</Button>
|
||||
</div>
|
||||
) : !readonly ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="text-editable truncate px-1 py-0.5 text-base font-semibold text-slate-700 dark:text-slate-300"
|
||||
onDoubleClick={enterEditMode}
|
||||
>
|
||||
{area.name}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('tool_tips.double_click_to_edit')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="truncate px-1 py-0.5 text-base font-semibold text-slate-700 dark:text-slate-300">
|
||||
{area.name}
|
||||
</div>
|
||||
)}
|
||||
{!editMode && !readonly && (
|
||||
{editMode && !readonly ? (
|
||||
<div className="flex w-full items-center">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={area.name}
|
||||
value={areaName}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) =>
|
||||
setAreaName(e.target.value)
|
||||
}
|
||||
className="h-6 bg-white/70 focus-visible:ring-0 dark:bg-slate-900/70"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="ml-auto size-5 p-0 opacity-0 transition-opacity hover:bg-white/20 group-hover:opacity-100"
|
||||
onClick={enterEditMode}
|
||||
className="ml-1 size-6 p-0 hover:bg-white/20"
|
||||
onClick={editAreaName}
|
||||
>
|
||||
<Pencil className="size-3 text-slate-700 dark:text-slate-300" />
|
||||
<Check className="size-3.5 text-slate-700 dark:text-slate-300" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : !readonly ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="text-editable max-w-[200px] cursor-text truncate px-1 py-0.5 text-base font-semibold text-slate-700 dark:text-slate-300"
|
||||
onDoubleClick={enterEditMode}
|
||||
>
|
||||
{area.name}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('tool_tips.double_click_to_edit')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="truncate px-1 py-0.5 text-base font-semibold text-slate-700 dark:text-slate-300">
|
||||
{area.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
</AreaNodeContextMenu>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -89,7 +89,6 @@ import { useDiagramFilter } from '@/context/diagram-filter-context/use-diagram-f
|
||||
import { filterTable } from '@/lib/domain/diagram-filter/filter';
|
||||
import { defaultSchemas } from '@/lib/data/default-schemas';
|
||||
import { useDiff } from '@/context/diff-context/use-diff';
|
||||
import { useClickAway } from 'react-use';
|
||||
|
||||
const HIGHLIGHTED_EDGE_Z_INDEX = 1;
|
||||
const DEFAULT_EDGE_Z_INDEX = 0;
|
||||
@@ -245,7 +244,6 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
||||
overlapGraph,
|
||||
showFilter,
|
||||
setShowFilter,
|
||||
setEditTableModeTable,
|
||||
} = useCanvas();
|
||||
const { filter, loading: filterLoading } = useDiagramFilter();
|
||||
const { checkIfNewTable } = useDiff();
|
||||
@@ -1215,13 +1213,6 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
||||
setTimeout(() => setHighlightOverlappingTables(false), 600);
|
||||
}, []);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const exitEditTableMode = useCallback(
|
||||
() => setEditTableModeTable(null),
|
||||
[setEditTableModeTable]
|
||||
);
|
||||
useClickAway(containerRef, exitEditTableMode);
|
||||
|
||||
const shiftPressed = useKeyPress('Shift');
|
||||
const operatingSystem = getOperatingSystem();
|
||||
|
||||
@@ -1239,11 +1230,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
||||
|
||||
return (
|
||||
<CanvasContextMenu>
|
||||
<div
|
||||
className="relative flex h-full"
|
||||
id="canvas"
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="relative flex h-full" id="canvas">
|
||||
<ReactFlow
|
||||
onlyRenderVisibleElements
|
||||
colorMode={effectiveTheme}
|
||||
@@ -1268,7 +1255,6 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
||||
panOnScroll={scrollAction === 'pan'}
|
||||
snapToGrid={shiftPressed || snapToGridEnabled}
|
||||
snapGrid={[20, 20]}
|
||||
onPaneClick={exitEditTableMode}
|
||||
>
|
||||
<Controls
|
||||
position="top-left"
|
||||
|
||||
60
src/pages/editor-page/canvas/table-node/table-edit-mode.css
Normal file
60
src/pages/editor-page/canvas/table-node/table-edit-mode.css
Normal file
@@ -0,0 +1,60 @@
|
||||
/* Custom scrollbar styles for table edit mode */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 transparent;
|
||||
}
|
||||
|
||||
/* Webkit browsers (Chrome, Safari, Edge) */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 6px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 6px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:active {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
/* Dark mode styles */
|
||||
.dark .custom-scrollbar {
|
||||
scrollbar-color: #475569 transparent;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb:active {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Always show scrollbar when content is scrollable */
|
||||
.custom-scrollbar:hover::-webkit-scrollbar-thumb,
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
visibility: visible;
|
||||
}
|
||||
859
src/pages/editor-page/canvas/table-node/table-edit-mode.tsx
Normal file
859
src/pages/editor-page/canvas/table-node/table-edit-mode.tsx
Normal file
@@ -0,0 +1,859 @@
|
||||
import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
memo,
|
||||
} from 'react';
|
||||
import { Button } from '@/components/button/button';
|
||||
import { Input } from '@/components/input/input';
|
||||
import { Plus, Trash2, X } from 'lucide-react';
|
||||
import type { DBField } from '@/lib/domain/db-field';
|
||||
import type { DBTable } from '@/lib/domain/db-table';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import { generateId } from '@/lib/utils';
|
||||
import type { SelectBoxOption } from '@/components/select-box/select-box';
|
||||
import { SelectBox } from '@/components/select-box/select-box';
|
||||
import { generateDBFieldSuffix } from '@/lib/domain/db-field';
|
||||
import { dataTypeDataToDataType } from '@/lib/data/data-types/data-types';
|
||||
import type { DataTypeData } from '@/lib/data/data-types/data-types';
|
||||
import { sortedDataTypeMap } from '@/lib/data/data-types/data-types';
|
||||
import { DatabaseType } from '@/lib/domain/database-type';
|
||||
import { Checkbox } from '@/components/checkbox/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import './table-edit-mode.css';
|
||||
|
||||
interface TableEditModeProps {
|
||||
table: DBTable;
|
||||
color?: string;
|
||||
focusFieldId?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface FieldRowProps {
|
||||
field: DBField;
|
||||
dataTypeOptions: SelectBoxOption[];
|
||||
databaseType: DatabaseType;
|
||||
onTypeChange: (
|
||||
fieldId: string,
|
||||
value: string,
|
||||
regexMatches?: string[]
|
||||
) => void;
|
||||
onNameChange: (
|
||||
fieldId: string,
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => void;
|
||||
onPrimaryKeyChange: (fieldId: string, checked: boolean) => void;
|
||||
onRemove: (fieldId: string) => void;
|
||||
inputRef?: (el: HTMLInputElement | null) => void;
|
||||
}
|
||||
|
||||
const FieldRow = memo<FieldRowProps>(
|
||||
({
|
||||
field,
|
||||
dataTypeOptions,
|
||||
databaseType,
|
||||
onTypeChange,
|
||||
onNameChange,
|
||||
onPrimaryKeyChange,
|
||||
onRemove,
|
||||
inputRef,
|
||||
}) => {
|
||||
return (
|
||||
<div className="mb-2 grid grid-cols-[1fr,150px,60px,40px] items-center gap-3 rounded-md p-2 hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={field.name}
|
||||
onChange={(e) => onNameChange(field.id, e)}
|
||||
className="h-9 text-sm font-medium"
|
||||
placeholder="Field name"
|
||||
/>
|
||||
|
||||
<SelectBox
|
||||
className="h-9 min-h-9 w-[150px] text-sm"
|
||||
popoverClassName="min-w-[350px]"
|
||||
options={dataTypeOptions}
|
||||
value={field.type.id}
|
||||
valueSuffix={generateDBFieldSuffix(field)}
|
||||
optionSuffix={(option) =>
|
||||
generateDBFieldSuffix(field, {
|
||||
databaseType,
|
||||
forceExtended: true,
|
||||
typeId: option.value,
|
||||
})
|
||||
}
|
||||
onChange={(value, regexMatches) =>
|
||||
onTypeChange(
|
||||
field.id,
|
||||
value as string,
|
||||
Array.isArray(regexMatches)
|
||||
? regexMatches
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
placeholder="Select type"
|
||||
emptyPlaceholder="No types found"
|
||||
/>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Checkbox
|
||||
checked={field.primaryKey || false}
|
||||
onCheckedChange={(checked) =>
|
||||
onPrimaryKeyChange(field.id, checked as boolean)
|
||||
}
|
||||
className="size-5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-8 p-0 hover:bg-red-100 dark:hover:bg-red-900"
|
||||
onClick={() => onRemove(field.id)}
|
||||
>
|
||||
<Trash2 className="size-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FieldRow.displayName = 'FieldRow';
|
||||
|
||||
// Helper function to generate field regex patterns
|
||||
const generateFieldRegexPatterns = (
|
||||
dataType: DataTypeData
|
||||
): {
|
||||
regex?: string;
|
||||
extractRegex?: RegExp;
|
||||
} => {
|
||||
if (!dataType.fieldAttributes) {
|
||||
return { regex: undefined, extractRegex: undefined };
|
||||
}
|
||||
|
||||
const typeName = dataType.name;
|
||||
const fieldAttributes = dataType.fieldAttributes;
|
||||
|
||||
if (fieldAttributes.hasCharMaxLength) {
|
||||
if (fieldAttributes.hasCharMaxLengthOption) {
|
||||
return {
|
||||
regex: `^${typeName}\\((\\d+|[mM][aA][xX])\\)$`,
|
||||
extractRegex: /\((\d+|max)\)/i,
|
||||
};
|
||||
}
|
||||
return {
|
||||
regex: `^${typeName}\\(\\d+\\)$`,
|
||||
extractRegex: /\((\d+)\)/,
|
||||
};
|
||||
}
|
||||
|
||||
if (fieldAttributes.precision && fieldAttributes.scale) {
|
||||
return {
|
||||
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*(?:,\\s*\\d+\\s*)?\\)$`,
|
||||
extractRegex: new RegExp(
|
||||
`${typeName}\\s*\\(\\s*(\\d+)\\s*(?:,\\s*(\\d+)\\s*)?\\)`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (fieldAttributes.precision) {
|
||||
return {
|
||||
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*\\)$`,
|
||||
extractRegex: /\((\d+)\)/,
|
||||
};
|
||||
}
|
||||
|
||||
return { regex: undefined, extractRegex: undefined };
|
||||
};
|
||||
|
||||
export const TableEditMode: React.FC<TableEditModeProps> = memo(
|
||||
({ table, color, focusFieldId, onClose }) => {
|
||||
const { updateTable, databaseType, customTypes } = useChartDB();
|
||||
const [tableName, setTableName] = useState(() => table.name);
|
||||
const [localFields, setLocalFields] = useState<DBField[]>(() =>
|
||||
(table.fields || []).map((field) => ({
|
||||
...field,
|
||||
primaryKey: field.primaryKey || false,
|
||||
}))
|
||||
);
|
||||
const [removedFieldIds, setRemovedFieldIds] = useState<string[]>(
|
||||
() => []
|
||||
);
|
||||
const [newlyCreatedFields, setNewlyCreatedFields] = useState<DBField[]>(
|
||||
() => []
|
||||
);
|
||||
const [newFieldId, setNewFieldId] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const tableNameInputRef = useRef<HTMLInputElement>(null);
|
||||
const fieldInputRefs = useRef<{
|
||||
[key: string]: HTMLInputElement | null;
|
||||
}>({});
|
||||
const hasInitialFocusedRef = useRef(false);
|
||||
|
||||
// Use refs to get latest state values in callbacks
|
||||
const tableNameRef = useRef(tableName);
|
||||
const localFieldsRef = useRef(localFields);
|
||||
const removedFieldIdsRef = useRef(removedFieldIds);
|
||||
const newlyCreatedFieldsRef = useRef(newlyCreatedFields);
|
||||
|
||||
useEffect(() => {
|
||||
tableNameRef.current = tableName;
|
||||
}, [tableName]);
|
||||
|
||||
useEffect(() => {
|
||||
localFieldsRef.current = localFields;
|
||||
}, [localFields]);
|
||||
|
||||
useEffect(() => {
|
||||
removedFieldIdsRef.current = removedFieldIds;
|
||||
}, [removedFieldIds]);
|
||||
|
||||
useEffect(() => {
|
||||
newlyCreatedFieldsRef.current = newlyCreatedFields;
|
||||
}, [newlyCreatedFields]);
|
||||
|
||||
const dataTypes = useMemo(
|
||||
() =>
|
||||
sortedDataTypeMap[databaseType] ||
|
||||
sortedDataTypeMap[DatabaseType.GENERIC],
|
||||
[databaseType]
|
||||
);
|
||||
|
||||
// Generate options for SelectBox similar to side panel
|
||||
const dataTypeOptions = useMemo(() => {
|
||||
const standardTypes: SelectBoxOption[] = dataTypes.map((type) => {
|
||||
const regexPatterns = generateFieldRegexPatterns(type);
|
||||
return {
|
||||
label: type.name,
|
||||
value: type.id,
|
||||
regex: regexPatterns.regex,
|
||||
extractRegex: regexPatterns.extractRegex,
|
||||
group: customTypes?.length ? 'Standard Types' : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
if (!customTypes?.length) {
|
||||
return standardTypes;
|
||||
}
|
||||
|
||||
// Add custom types as options
|
||||
const customTypeOptions: SelectBoxOption[] = customTypes.map(
|
||||
(type) => ({
|
||||
label: type.name,
|
||||
value: type.name,
|
||||
description:
|
||||
type.kind === 'enum'
|
||||
? `${type.values?.join(' | ')}`
|
||||
: '',
|
||||
group: 'Custom Types',
|
||||
})
|
||||
);
|
||||
|
||||
return [...standardTypes, ...customTypeOptions];
|
||||
}, [dataTypes, customTypes]);
|
||||
|
||||
// Focus on specific field when opened from pencil click, or table name when double-clicked
|
||||
useEffect(() => {
|
||||
// Only perform initial focus once
|
||||
if (hasInitialFocusedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasInitialFocusedRef.current = true;
|
||||
|
||||
if (focusFieldId && scrollContainerRef.current) {
|
||||
// Find the field index to calculate scroll position
|
||||
const fieldIndex = localFields.findIndex(
|
||||
(f) => f.id === focusFieldId
|
||||
);
|
||||
if (fieldIndex !== -1) {
|
||||
// Scroll to the field (each field is approximately 56px height)
|
||||
const scrollPosition = fieldIndex * 56;
|
||||
scrollContainerRef.current.scrollTo({
|
||||
top: scrollPosition,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
|
||||
// Focus and select the field name after scroll
|
||||
setTimeout(() => {
|
||||
const input = fieldInputRefs.current[focusFieldId];
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
} else if (!focusFieldId && tableNameInputRef.current) {
|
||||
// Only focus table name on initial mount when no specific field is targeted
|
||||
// This prevents focus jumping when typing in field names
|
||||
setTimeout(() => {
|
||||
if (tableNameInputRef.current) {
|
||||
tableNameInputRef.current.focus();
|
||||
tableNameInputRef.current.select();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [focusFieldId, localFields]);
|
||||
|
||||
// Focus and select text when a new field is added
|
||||
useEffect(() => {
|
||||
if (newFieldId) {
|
||||
// Wait for the next render cycle and for the scroll to complete
|
||||
const focusTimer = setTimeout(() => {
|
||||
const input = fieldInputRefs.current[newFieldId];
|
||||
if (input) {
|
||||
input.focus();
|
||||
// Small delay to ensure focus is set before selecting
|
||||
setTimeout(() => {
|
||||
input.select();
|
||||
}, 50);
|
||||
}
|
||||
setNewFieldId(null);
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(focusTimer);
|
||||
}
|
||||
}, [newFieldId]);
|
||||
|
||||
// Save all changes when closing the edit mode
|
||||
const saveAllChanges = useCallback(() => {
|
||||
const currentTableName = tableNameRef.current;
|
||||
const currentLocalFields = localFieldsRef.current;
|
||||
const currentRemovedFieldIds = removedFieldIdsRef.current;
|
||||
const currentNewlyCreatedFields = newlyCreatedFieldsRef.current;
|
||||
|
||||
// Always save to ensure field changes are persisted
|
||||
// Build the final fields array with all changes
|
||||
const finalFields: DBField[] = [];
|
||||
|
||||
// Process all fields - both existing and new
|
||||
for (const field of currentLocalFields) {
|
||||
const isNewField = currentNewlyCreatedFields.some(
|
||||
(f) => f.id === field.id
|
||||
);
|
||||
|
||||
if (isNewField) {
|
||||
// For new fields, replace temp ID with a proper ID
|
||||
finalFields.push({
|
||||
...field,
|
||||
id: generateId(), // Generate a proper ID for the new field
|
||||
primaryKey: field.primaryKey || false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} else if (!currentRemovedFieldIds.includes(field.id)) {
|
||||
// Existing field that wasn't removed - ensure all properties are included
|
||||
finalFields.push({
|
||||
...field,
|
||||
primaryKey: field.primaryKey || false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build the update object with all changes
|
||||
const tableUpdates: Partial<DBTable> = {
|
||||
fields: finalFields,
|
||||
};
|
||||
|
||||
// Add name change if needed
|
||||
if (currentTableName.trim() && currentTableName !== table.name) {
|
||||
tableUpdates.name = currentTableName.trim();
|
||||
}
|
||||
|
||||
// Make a single update call with all changes
|
||||
// Return the promise so we can handle it properly
|
||||
return updateTable(table.id, tableUpdates);
|
||||
}, [table, updateTable]);
|
||||
|
||||
// Save on unmount if there are changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Clean up refs
|
||||
fieldInputRefs.current = {};
|
||||
|
||||
// Save any pending changes when component unmounts
|
||||
// This ensures changes are saved even if the component is unmounted quickly
|
||||
|
||||
// Check if any existing fields have been modified
|
||||
const fieldsModified = localFieldsRef.current.some(
|
||||
(localField) => {
|
||||
const originalField = table.fields.find(
|
||||
(f) => f.id === localField.id
|
||||
);
|
||||
if (!originalField) return false; // This is a new field
|
||||
|
||||
// Check if any properties have changed
|
||||
return (
|
||||
originalField.name !== localField.name ||
|
||||
(originalField.primaryKey || false) !==
|
||||
(localField.primaryKey || false) ||
|
||||
originalField.type.id !== localField.type.id ||
|
||||
originalField.nullable !== localField.nullable ||
|
||||
originalField.unique !== localField.unique
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const hasChanges =
|
||||
tableNameRef.current !== table.name ||
|
||||
localFieldsRef.current.length !== table.fields.length ||
|
||||
removedFieldIdsRef.current.length > 0 ||
|
||||
newlyCreatedFieldsRef.current.length > 0 ||
|
||||
fieldsModified;
|
||||
|
||||
if (hasChanges) {
|
||||
// Use the refs directly since the component is unmounting
|
||||
const finalFields: DBField[] = [];
|
||||
for (const field of localFieldsRef.current) {
|
||||
const isNewField = newlyCreatedFieldsRef.current.some(
|
||||
(f) => f.id === field.id
|
||||
);
|
||||
if (isNewField) {
|
||||
finalFields.push({
|
||||
...field,
|
||||
id: generateId(),
|
||||
primaryKey: field.primaryKey || false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} else if (
|
||||
!removedFieldIdsRef.current.includes(field.id)
|
||||
) {
|
||||
finalFields.push({
|
||||
...field,
|
||||
primaryKey: field.primaryKey || false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tableUpdates: Partial<DBTable> = {
|
||||
fields: finalFields,
|
||||
};
|
||||
|
||||
if (
|
||||
tableNameRef.current.trim() &&
|
||||
tableNameRef.current !== table.name
|
||||
) {
|
||||
tableUpdates.name = tableNameRef.current.trim();
|
||||
}
|
||||
|
||||
// Fire and forget - component is unmounting
|
||||
updateTable(table.id, tableUpdates);
|
||||
}
|
||||
};
|
||||
}, [table, updateTable]);
|
||||
|
||||
// Handle click outside - using both mousedown and click for better compatibility
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
// Check if click is inside the edit mode container
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
// Check if the click is on a select dropdown portal element
|
||||
const target = event.target as HTMLElement;
|
||||
const isSelectPortal =
|
||||
target.closest('[data-radix-select-viewport]') ||
|
||||
target.closest('[role="listbox"]') ||
|
||||
target.closest('[data-radix-popper-content-wrapper]') ||
|
||||
target.closest('[data-state="open"]') ||
|
||||
target.closest('[data-radix-select-content]');
|
||||
|
||||
// Don't close if clicking on select dropdown
|
||||
if (isSelectPortal) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
// Save and close - optimized approach
|
||||
const savePromise = saveAllChanges();
|
||||
onClose();
|
||||
// Ensure save completes even after component unmounts
|
||||
if (savePromise) {
|
||||
savePromise.catch((error) => {
|
||||
console.error(
|
||||
'Failed to save table changes:',
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Prevent wheel events from propagating to the canvas
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener after a very small delay to avoid immediate closing
|
||||
const timer = setTimeout(() => {
|
||||
// Only use mousedown to handle outside clicks
|
||||
// Using both mousedown and click can cause issues with dropdowns
|
||||
document.addEventListener(
|
||||
'mousedown',
|
||||
handleClickOutside,
|
||||
true
|
||||
);
|
||||
document.addEventListener('wheel', handleWheel, {
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
}, 50);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener(
|
||||
'mousedown',
|
||||
handleClickOutside,
|
||||
true
|
||||
);
|
||||
document.removeEventListener('wheel', handleWheel, true);
|
||||
};
|
||||
}, [onClose, saveAllChanges]);
|
||||
|
||||
const handleTableNameChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTableName(e.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleFieldNameChange = useCallback(
|
||||
(fieldId: string, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newName = e.target.value;
|
||||
setLocalFields((prev) => {
|
||||
const newFields = [...prev];
|
||||
const index = newFields.findIndex((f) => f.id === fieldId);
|
||||
if (index !== -1) {
|
||||
newFields[index] = {
|
||||
...newFields[index],
|
||||
name: newName,
|
||||
};
|
||||
}
|
||||
return newFields;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleFieldTypeChange = useCallback(
|
||||
(fieldId: string, typeId: string, regexMatches?: string[]) => {
|
||||
const field = localFields.find((f) => f.id === fieldId);
|
||||
if (!field) return;
|
||||
|
||||
const dataType = dataTypes.find((v) => v.id === typeId) ?? {
|
||||
id: typeId,
|
||||
name: typeId,
|
||||
};
|
||||
|
||||
let characterMaximumLength: string | undefined = undefined;
|
||||
let precision: number | undefined = undefined;
|
||||
let scale: number | undefined = undefined;
|
||||
|
||||
if (regexMatches?.length) {
|
||||
if (dataType?.fieldAttributes?.hasCharMaxLength) {
|
||||
characterMaximumLength = regexMatches[1]?.toLowerCase();
|
||||
} else if (
|
||||
dataType?.fieldAttributes?.precision &&
|
||||
dataType?.fieldAttributes?.scale
|
||||
) {
|
||||
precision = parseInt(regexMatches[1]);
|
||||
scale = regexMatches[2]
|
||||
? parseInt(regexMatches[2])
|
||||
: undefined;
|
||||
} else if (dataType?.fieldAttributes?.precision) {
|
||||
precision = parseInt(regexMatches[1]);
|
||||
}
|
||||
} else {
|
||||
// Preserve existing values if compatible
|
||||
if (
|
||||
dataType?.fieldAttributes?.hasCharMaxLength &&
|
||||
field.characterMaximumLength
|
||||
) {
|
||||
characterMaximumLength = field.characterMaximumLength;
|
||||
}
|
||||
|
||||
if (
|
||||
dataType?.fieldAttributes?.precision &&
|
||||
field.precision
|
||||
) {
|
||||
precision = field.precision;
|
||||
}
|
||||
|
||||
if (dataType?.fieldAttributes?.scale && field.scale) {
|
||||
scale = field.scale;
|
||||
}
|
||||
}
|
||||
|
||||
setLocalFields((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === fieldId
|
||||
? {
|
||||
...f,
|
||||
type: dataTypeDataToDataType(dataType),
|
||||
characterMaximumLength,
|
||||
precision,
|
||||
scale,
|
||||
increment: undefined,
|
||||
default: undefined,
|
||||
}
|
||||
: f
|
||||
)
|
||||
);
|
||||
},
|
||||
[dataTypes, localFields]
|
||||
);
|
||||
|
||||
const handleFieldPrimaryKeyChange = useCallback(
|
||||
(fieldId: string, primaryKey: boolean) => {
|
||||
setLocalFields((prev) =>
|
||||
prev.map((field) =>
|
||||
field.id === fieldId
|
||||
? { ...field, primaryKey: primaryKey }
|
||||
: field
|
||||
)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleAddField = useCallback(() => {
|
||||
// Create a temporary field locally without saving to database
|
||||
// Default to varchar(100) if available, otherwise use first type
|
||||
const varcharType = dataTypes.find(
|
||||
(dt) => dt.id === 'varchar' || dt.id === 'character_varying'
|
||||
);
|
||||
const defaultType = varcharType ||
|
||||
dataTypes[0] || { id: 'text', name: 'text' };
|
||||
|
||||
const tempField: DBField = {
|
||||
id: `temp-${generateId()}`, // Temporary ID
|
||||
name: `field${localFields.length + 1}`,
|
||||
type: dataTypeDataToDataType(defaultType),
|
||||
characterMaximumLength: varcharType ? '100' : undefined,
|
||||
nullable: true,
|
||||
unique: false,
|
||||
primaryKey: false,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
setLocalFields((prev) => [...prev, tempField]);
|
||||
setNewlyCreatedFields((prev) => [...prev, tempField]);
|
||||
setNewFieldId(tempField.id);
|
||||
|
||||
// Scroll to bottom after a minimal delay to ensure the new field is rendered
|
||||
requestAnimationFrame(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
top: scrollContainerRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [localFields.length, dataTypes]);
|
||||
|
||||
const handleRemoveField = useCallback(
|
||||
(fieldId: string) => {
|
||||
// Check if this field was created in this session
|
||||
const isNewField = newlyCreatedFields.some(
|
||||
(f) => f.id === fieldId
|
||||
);
|
||||
|
||||
if (isNewField) {
|
||||
// Just remove from local state, it was never saved to database
|
||||
setNewlyCreatedFields((prev) =>
|
||||
prev.filter((f) => f.id !== fieldId)
|
||||
);
|
||||
setLocalFields((prev) =>
|
||||
prev.filter((field) => field.id !== fieldId)
|
||||
);
|
||||
} else {
|
||||
// Mark existing field for removal on close
|
||||
setRemovedFieldIds((prev) => [...prev, fieldId]);
|
||||
setLocalFields((prev) =>
|
||||
prev.filter((field) => field.id !== fieldId)
|
||||
);
|
||||
}
|
||||
},
|
||||
[newlyCreatedFields]
|
||||
);
|
||||
|
||||
// Calculate dynamic height based on number of fields
|
||||
// Max height is 80% of viewport or 600px, whichever is smaller
|
||||
const maxHeight = Math.min(window.innerHeight * 0.8, 600);
|
||||
const calculatedHeight = 240 + localFields.length * 56; // header + fields + button + padding
|
||||
const editModeHeight = Math.min(
|
||||
maxHeight,
|
||||
Math.max(320, calculatedHeight)
|
||||
);
|
||||
const isScrollable = calculatedHeight > maxHeight;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Invisible overlay to capture clicks in the canvas */}
|
||||
<div
|
||||
className="fixed inset-[-9999px] z-40"
|
||||
style={{ width: '99999px', height: '99999px' }}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
// Save and close
|
||||
const savePromise = saveAllChanges();
|
||||
onClose();
|
||||
// Ensure save completes
|
||||
if (savePromise) {
|
||||
savePromise.catch((error) => {
|
||||
console.error(
|
||||
'Failed to save table changes:',
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
// eslint-disable-next-line tailwindcss/no-custom-classname
|
||||
className="nowheel nopan nodrag absolute z-50 flex min-w-[500px] flex-col rounded-lg border-2 border-blue-500 bg-white shadow-2xl dark:bg-slate-950"
|
||||
style={{
|
||||
left: '-50%',
|
||||
right: '-50%',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
height: `${editModeHeight}px`,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerMove={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Color header bar */}
|
||||
<div
|
||||
className="h-2 rounded-t-[6px]"
|
||||
style={{ backgroundColor: color || '#6b7280' }}
|
||||
/>
|
||||
<div className="flex items-center justify-between border-b bg-slate-100 p-4 dark:bg-slate-900">
|
||||
<Input
|
||||
ref={tableNameInputRef}
|
||||
value={tableName}
|
||||
onChange={handleTableNameChange}
|
||||
className="mr-3 h-10 flex-1 text-base font-bold"
|
||||
placeholder="Table name"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Save and close
|
||||
const savePromise = saveAllChanges();
|
||||
onClose();
|
||||
// Ensure save completes
|
||||
if (savePromise) {
|
||||
savePromise.catch((error) => {
|
||||
console.error(
|
||||
'Failed to save table changes:',
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="hover:bg-slate-200 dark:hover:bg-slate-800"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden">
|
||||
<div className="p-4 pb-0">
|
||||
{localFields.length > 0 && (
|
||||
<div className="mb-3 grid grid-cols-[1fr,150px,60px,40px] gap-3 px-2 text-sm font-semibold text-slate-700 dark:text-slate-300">
|
||||
<div>Field Name</div>
|
||||
<div>Type</div>
|
||||
<div className="text-center">PK</div>
|
||||
<div></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto px-4 pr-3 custom-scrollbar relative nowheel',
|
||||
isScrollable && 'pb-2'
|
||||
)}
|
||||
style={{
|
||||
scrollbarGutter: 'stable',
|
||||
overflowY:
|
||||
localFields.length > 0 ? 'auto' : 'hidden',
|
||||
}}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onPointerMove={(e) => e.stopPropagation()}
|
||||
onMouseMove={(e) => e.stopPropagation()}
|
||||
>
|
||||
{localFields.length > 0 ? (
|
||||
<>
|
||||
{localFields.map((field) => (
|
||||
<FieldRow
|
||||
key={field.id}
|
||||
field={field}
|
||||
dataTypeOptions={dataTypeOptions}
|
||||
databaseType={databaseType}
|
||||
onNameChange={handleFieldNameChange}
|
||||
onTypeChange={handleFieldTypeChange}
|
||||
onPrimaryKeyChange={
|
||||
handleFieldPrimaryKeyChange
|
||||
}
|
||||
onRemove={handleRemoveField}
|
||||
inputRef={(el) => {
|
||||
if (el)
|
||||
fieldInputRefs.current[
|
||||
field.id
|
||||
] = el;
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="py-8 text-center text-base text-slate-500 dark:text-slate-400">
|
||||
No fields yet. Click "Add Field" to create
|
||||
one.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fade overlay at bottom when scrollable */}
|
||||
{isScrollable && localFields.length > 5 && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-white to-transparent dark:from-slate-950" />
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex items-center justify-between gap-4 border-t bg-slate-50 p-4 dark:bg-slate-900">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="h-11 max-w-[45%] flex-1 text-base"
|
||||
onClick={handleAddField}
|
||||
>
|
||||
<Plus className="mr-2 size-5" />
|
||||
Add Field
|
||||
</Button>
|
||||
<span className="mr-2 text-sm font-medium text-slate-600 dark:text-slate-400">
|
||||
{localFields.length}{' '}
|
||||
{localFields.length === 1
|
||||
? 'Column'
|
||||
: 'Columns'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TableEditMode.displayName = 'TableEditMode';
|
||||
@@ -1,180 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { KeyRound, Trash2 } from 'lucide-react';
|
||||
import { Input } from '@/components/input/input';
|
||||
import { generateDBFieldSuffix, type DBField } from '@/lib/domain/db-field';
|
||||
import type { DBTable } from '@/lib/domain';
|
||||
import { useUpdateTableField } from '@/hooks/use-update-table-field';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/tooltip/tooltip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SelectBox } from '@/components/select-box/select-box';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TableFieldToggle } from './table-field-toggle';
|
||||
|
||||
export interface TableEditModeFieldProps {
|
||||
table: DBTable;
|
||||
field: DBField;
|
||||
focused?: boolean;
|
||||
}
|
||||
|
||||
export const TableEditModeField: React.FC<TableEditModeFieldProps> = React.memo(
|
||||
({ table, field, focused = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const [showHighlight, setShowHighlight] = React.useState(false);
|
||||
|
||||
const {
|
||||
dataFieldOptions,
|
||||
handleDataTypeChange,
|
||||
handlePrimaryKeyToggle,
|
||||
handleNullableToggle,
|
||||
handleNameChange,
|
||||
generateFieldSuffix,
|
||||
fieldName,
|
||||
nullable,
|
||||
primaryKey,
|
||||
removeField,
|
||||
} = useUpdateTableField(table, field);
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Animate the highlight after mount if focused
|
||||
useEffect(() => {
|
||||
if (focused) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowHighlight(true);
|
||||
inputRef.current?.select();
|
||||
|
||||
setTimeout(() => {
|
||||
setShowHighlight(false);
|
||||
}, 2000);
|
||||
}, 200); // Small delay for the animation to be noticeable
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setShowHighlight(false);
|
||||
}
|
||||
}, [focused]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 flex-row justify-between gap-2 p-1 transition-colors duration-1000 ease-out',
|
||||
{
|
||||
'bg-sky-100 dark:bg-sky-950': showHighlight,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1 items-center justify-start gap-1 overflow-hidden">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="min-w-0 flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="h-8 w-full !truncate bg-background focus-visible:ring-0"
|
||||
type="text"
|
||||
placeholder={t(
|
||||
'side_panel.tables_section.table.field_name'
|
||||
)}
|
||||
value={fieldName}
|
||||
onChange={(e) =>
|
||||
handleNameChange(e.target.value)
|
||||
}
|
||||
autoFocus={focused}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{fieldName}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className="flex h-8 min-w-0 flex-1"
|
||||
asChild
|
||||
>
|
||||
<span>
|
||||
<SelectBox
|
||||
className="flex h-8 min-h-8 w-full bg-background"
|
||||
popoverClassName="min-w-[200px]"
|
||||
options={dataFieldOptions}
|
||||
placeholder={t(
|
||||
'side_panel.tables_section.table.field_type'
|
||||
)}
|
||||
value={field.type.id}
|
||||
valueSuffix={generateDBFieldSuffix(field)}
|
||||
optionSuffix={(option) =>
|
||||
generateFieldSuffix(option.value)
|
||||
}
|
||||
onChange={handleDataTypeChange}
|
||||
emptyPlaceholder={t(
|
||||
'side_panel.tables_section.table.no_types_found'
|
||||
)}
|
||||
commandOnClick={(e) => e.stopPropagation()}
|
||||
commandOnMouseDown={(e) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{field.type.name}
|
||||
{field.characterMaximumLength
|
||||
? `(${field.characterMaximumLength})`
|
||||
: ''}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<TableFieldToggle
|
||||
pressed={nullable}
|
||||
onPressedChange={handleNullableToggle}
|
||||
>
|
||||
N
|
||||
</TableFieldToggle>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('side_panel.tables_section.table.nullable')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<TableFieldToggle
|
||||
pressed={primaryKey}
|
||||
onPressedChange={handlePrimaryKeyToggle}
|
||||
>
|
||||
<KeyRound className="h-3.5" />
|
||||
</TableFieldToggle>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('side_panel.tables_section.table.primary_key')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<TableFieldToggle onPressedChange={removeField}>
|
||||
<Trash2 className="h-3.5 text-red-700" />
|
||||
</TableFieldToggle>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t(
|
||||
'side_panel.tables_section.table.field_actions.delete_field'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TableEditModeField.displayName = 'TableEditModeField';
|
||||
@@ -1,205 +0,0 @@
|
||||
import { Input } from '@/components/input/input';
|
||||
import type { DBTable } from '@/lib/domain';
|
||||
import { FileType2, X } from 'lucide-react';
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { TableEditModeField } from './table-edit-mode-field';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScrollArea } from '@/components/scroll-area/scroll-area';
|
||||
import { Button } from '@/components/button/button';
|
||||
import { ColorPicker } from '@/components/color-picker/color-picker';
|
||||
import { Separator } from '@/components/separator/separator';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import { useUpdateTable } from '@/hooks/use-update-table';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useClickOutside } from '@/hooks/use-click-outside';
|
||||
|
||||
export interface TableEditModeProps {
|
||||
table: DBTable;
|
||||
color: string;
|
||||
focusFieldId?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
|
||||
({ table, color, focusFieldId: focusFieldIdProp, onClose }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const fieldRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const { createField, updateTable } = useChartDB();
|
||||
const { t } = useTranslation();
|
||||
const { tableName, handleTableNameChange } = useUpdateTable(table);
|
||||
const [focusFieldId, setFocusFieldId] = useState<string | undefined>(
|
||||
focusFieldIdProp
|
||||
);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFocusFieldId(focusFieldIdProp);
|
||||
if (!focusFieldIdProp) {
|
||||
inputRef.current?.select();
|
||||
}
|
||||
}, [focusFieldIdProp]);
|
||||
|
||||
// Callback to store field refs
|
||||
const setFieldRef = useCallback((fieldId: string) => {
|
||||
return (element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
fieldRefs.current.set(fieldId, element);
|
||||
} else {
|
||||
fieldRefs.current.delete(fieldId);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Trigger animation after mount
|
||||
requestAnimationFrame(() => {
|
||||
setIsVisible(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const scrollToFieldId = useCallback((fieldId: string) => {
|
||||
const fieldElement = fieldRefs.current.get(fieldId);
|
||||
if (fieldElement) {
|
||||
fieldElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Scroll to focused field when component mounts
|
||||
useEffect(() => {
|
||||
if (focusFieldId) {
|
||||
scrollToFieldId(focusFieldId);
|
||||
}
|
||||
}, [focusFieldId, scrollToFieldId]);
|
||||
|
||||
// Handle wheel events: allow zoom to pass through, but handle scroll locally
|
||||
useEffect(() => {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// If Ctrl or Cmd is pressed, it's a zoom gesture - let it pass through to canvas
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, it's a scroll - stop propagation to prevent canvas panning
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const scrollArea = scrollAreaRef.current;
|
||||
if (scrollArea) {
|
||||
// Use passive: false to allow preventDefault if needed
|
||||
scrollArea.addEventListener('wheel', handleWheel, {
|
||||
passive: false,
|
||||
});
|
||||
|
||||
return () => {
|
||||
scrollArea.removeEventListener('wheel', handleWheel);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAddField = useCallback(async () => {
|
||||
const field = await createField(table.id);
|
||||
|
||||
if (field.id) {
|
||||
setFocusFieldId(field.id);
|
||||
}
|
||||
}, [createField, table.id]);
|
||||
|
||||
// Close edit mode when clicking outside
|
||||
useClickOutside(containerRef, onClose, isVisible);
|
||||
|
||||
const handleColorChange = useCallback(
|
||||
(newColor: string) => {
|
||||
updateTable(table.id, { color: newColor });
|
||||
},
|
||||
[updateTable, table.id]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'flex z-50 border-slate-500 dark:border-slate-700 flex-col border-2 bg-slate-50 dark:bg-slate-950 rounded-lg shadow-lg absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-auto transition-all duration-100 ease-out',
|
||||
{
|
||||
'opacity-100 scale-100': isVisible,
|
||||
'opacity-0 scale-95': !isVisible,
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
minHeight: '300px',
|
||||
minWidth: '350px',
|
||||
height: 'max(calc(100% + 48px), 200px)',
|
||||
width: 'max(calc(100% + 48px), 300px)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="h-2 rounded-t-[6px]"
|
||||
style={{ backgroundColor: color }}
|
||||
></div>
|
||||
<div className="group flex h-9 items-center justify-between gap-2 bg-slate-200 px-2 dark:bg-slate-900">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={handleColorChange}
|
||||
disabled={table.isView}
|
||||
popoverOnMouseDown={(e) => e.stopPropagation()}
|
||||
popoverOnClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="h-6 flex-1 rounded-sm border-slate-600 bg-background text-sm"
|
||||
placeholder="Table name"
|
||||
value={tableName}
|
||||
onChange={(e) =>
|
||||
handleTableNameChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-6 p-0 hover:bg-slate-300 dark:hover:bg-slate-700"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea ref={scrollAreaRef} className="nodrag flex-1 p-2">
|
||||
{table.fields.map((field) => (
|
||||
<div key={field.id} ref={setFieldRef(field.id)}>
|
||||
<TableEditModeField
|
||||
table={table}
|
||||
field={field}
|
||||
focused={focusFieldId === field.id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between p-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 p-2 text-xs"
|
||||
onClick={handleAddField}
|
||||
>
|
||||
<FileType2 className="mr-1 h-4" />
|
||||
{t('side_panel.tables_section.table.add_field')}
|
||||
</Button>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{table.fields.length}{' '}
|
||||
{t('side_panel.tables_section.table.fields')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TableEditMode.displayName = 'TableEditMode';
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Toggle } from '@/components/toggle/toggle';
|
||||
|
||||
export const TableFieldToggle = React.forwardRef<
|
||||
React.ElementRef<typeof Toggle>,
|
||||
React.ComponentPropsWithoutRef<typeof Toggle>
|
||||
>((props, ref) => {
|
||||
return (
|
||||
<Toggle
|
||||
{...props}
|
||||
ref={ref}
|
||||
variant="default"
|
||||
className="h-8 w-[32px] p-2 text-xs text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TableFieldToggle.displayName = Toggle.displayName;
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@xyflow/react';
|
||||
import { Button } from '@/components/button/button';
|
||||
import {
|
||||
Check,
|
||||
KeyRound,
|
||||
MessageCircleMore,
|
||||
SquareDot,
|
||||
@@ -28,14 +29,14 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/tooltip/tooltip';
|
||||
import { useClickAway, useKeyPressEvent } from 'react-use';
|
||||
import { Input } from '@/components/input/input';
|
||||
import { useDiff } from '@/context/diff-context/use-diff';
|
||||
import { useLocalConfig } from '@/hooks/use-local-config';
|
||||
import {
|
||||
BOTTOM_SOURCE_HANDLE_ID_PREFIX,
|
||||
TOP_SOURCE_HANDLE_ID_PREFIX,
|
||||
} from './table-node-dependency-indicator';
|
||||
import { useCanvas } from '@/hooks/use-canvas';
|
||||
import { useLayout } from '@/hooks/use-layout';
|
||||
|
||||
export const LEFT_HANDLE_ID_PREFIX = 'left_rel_';
|
||||
export const RIGHT_HANDLE_ID_PREFIX = 'right_rel_';
|
||||
@@ -48,6 +49,7 @@ export interface TableNodeFieldProps {
|
||||
highlighted: boolean;
|
||||
visible: boolean;
|
||||
isConnectable: boolean;
|
||||
onOpenEditMode?: () => void;
|
||||
}
|
||||
|
||||
const arePropsEqual = (
|
||||
@@ -71,13 +73,26 @@ const arePropsEqual = (
|
||||
prevProps.highlighted === nextProps.highlighted &&
|
||||
prevProps.visible === nextProps.visible &&
|
||||
prevProps.isConnectable === nextProps.isConnectable &&
|
||||
prevProps.tableNodeId === nextProps.tableNodeId
|
||||
prevProps.tableNodeId === nextProps.tableNodeId &&
|
||||
prevProps.onOpenEditMode === nextProps.onOpenEditMode
|
||||
);
|
||||
};
|
||||
|
||||
export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
|
||||
({ field, focused, tableNodeId, highlighted, visible, isConnectable }) => {
|
||||
const { relationships, readonly, highlightedCustomType } = useChartDB();
|
||||
({
|
||||
field,
|
||||
focused,
|
||||
tableNodeId,
|
||||
highlighted,
|
||||
visible,
|
||||
isConnectable,
|
||||
onOpenEditMode,
|
||||
}) => {
|
||||
const { relationships, readonly, updateField, highlightedCustomType } =
|
||||
useChartDB();
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [fieldName, setFieldName] = useState(field.name);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const updateNodeInternals = useUpdateNodeInternals();
|
||||
const connection = useConnection();
|
||||
@@ -142,6 +157,23 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
|
||||
}
|
||||
}, [tableNodeId, updateNodeInternals, numberOfEdgesToField]);
|
||||
|
||||
const editFieldName = useCallback(() => {
|
||||
if (!editMode) return;
|
||||
if (fieldName.trim()) {
|
||||
updateField(tableNodeId, field.id, { name: fieldName.trim() });
|
||||
}
|
||||
setEditMode(false);
|
||||
}, [fieldName, field.id, updateField, editMode, tableNodeId]);
|
||||
|
||||
const abortEdit = useCallback(() => {
|
||||
setEditMode(false);
|
||||
setFieldName(field.name);
|
||||
}, [field.name]);
|
||||
|
||||
useClickAway(inputRef, editFieldName);
|
||||
useKeyPressEvent('Enter', editFieldName);
|
||||
useKeyPressEvent('Escape', abortEdit);
|
||||
|
||||
const {
|
||||
checkIfFieldRemoved,
|
||||
checkIfNewField,
|
||||
@@ -245,32 +277,17 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
|
||||
fieldDiffChangedPrecision,
|
||||
} = diffState;
|
||||
|
||||
const enterEditMode = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditMode(true);
|
||||
}, []);
|
||||
|
||||
const isCustomTypeHighlighted = useMemo(() => {
|
||||
if (!highlightedCustomType) return false;
|
||||
return field.type.name === highlightedCustomType.name;
|
||||
}, [highlightedCustomType, field.type.name]);
|
||||
const { showFieldAttributes } = useLocalConfig();
|
||||
|
||||
const { closeAllTablesInSidebar } = useLayout();
|
||||
const { setEditTableModeTable } = useCanvas();
|
||||
const openEditTableOnField = useCallback(() => {
|
||||
if (readonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeAllTablesInSidebar();
|
||||
setEditTableModeTable({
|
||||
tableId: tableNodeId,
|
||||
fieldId: field.id,
|
||||
});
|
||||
}, [
|
||||
setEditTableModeTable,
|
||||
closeAllTablesInSidebar,
|
||||
tableNodeId,
|
||||
field.id,
|
||||
readonly,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -342,6 +359,7 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
|
||||
'flex items-center gap-1 min-w-0 flex-1 text-left',
|
||||
{
|
||||
'font-semibold': field.primaryKey || field.unique,
|
||||
'w-full': editMode,
|
||||
}
|
||||
)}
|
||||
>
|
||||
@@ -352,31 +370,54 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
|
||||
) : isDiffFieldChanged && !isSummaryOnly ? (
|
||||
<SquareDot className="size-3.5 shrink-0 text-sky-800 dark:text-sky-200" />
|
||||
) : null}
|
||||
|
||||
<span
|
||||
className={cn('truncate min-w-0', {
|
||||
'text-red-800 font-normal dark:text-red-200':
|
||||
isDiffFieldRemoved,
|
||||
'text-green-800 font-normal dark:text-green-200':
|
||||
isDiffNewField,
|
||||
'text-sky-800 font-normal dark:text-sky-200':
|
||||
isDiffFieldChanged &&
|
||||
!isSummaryOnly &&
|
||||
!isDiffFieldRemoved &&
|
||||
!isDiffNewField,
|
||||
})}
|
||||
>
|
||||
{fieldDiffChangedName ? (
|
||||
<>
|
||||
{field.name}{' '}
|
||||
<span className="font-medium">→</span>{' '}
|
||||
{fieldDiffChangedName}
|
||||
</>
|
||||
) : (
|
||||
field.name
|
||||
)}
|
||||
</span>
|
||||
{field.comments ? (
|
||||
{editMode && !readonly ? (
|
||||
<>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
onBlur={editFieldName}
|
||||
placeholder={field.name}
|
||||
autoFocus
|
||||
type="text"
|
||||
value={fieldName}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setFieldName(e.target.value)}
|
||||
className="h-5 w-full border-[0.5px] border-blue-400 bg-slate-100 focus-visible:ring-0 dark:bg-slate-900"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||||
onClick={editFieldName}
|
||||
>
|
||||
<Check className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
className={cn('truncate min-w-0', {
|
||||
'text-red-800 font-normal dark:text-red-200':
|
||||
isDiffFieldRemoved,
|
||||
'text-green-800 font-normal dark:text-green-200':
|
||||
isDiffNewField,
|
||||
'text-sky-800 font-normal dark:text-sky-200':
|
||||
isDiffFieldChanged &&
|
||||
!isSummaryOnly &&
|
||||
!isDiffFieldRemoved &&
|
||||
!isDiffNewField,
|
||||
})}
|
||||
onDoubleClick={enterEditMode}
|
||||
>
|
||||
{fieldDiffChangedName ? (
|
||||
<>
|
||||
{field.name}{' '}
|
||||
<span className="font-medium">→</span>{' '}
|
||||
{fieldDiffChangedName}
|
||||
</>
|
||||
) : (
|
||||
field.name
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{field.comments && !editMode ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="shrink-0 cursor-pointer text-muted-foreground">
|
||||
@@ -387,14 +428,37 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
{editMode ? null : (
|
||||
<div className="ml-2 flex shrink-0 items-center justify-end gap-1.5">
|
||||
{(field.primaryKey &&
|
||||
fieldDiffChangedPrimaryKey === null) ||
|
||||
fieldDiffChangedPrimaryKey ? (
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground',
|
||||
!readonly ? 'group-hover:hidden' : '',
|
||||
isDiffFieldRemoved
|
||||
? 'text-red-800 dark:text-red-200'
|
||||
: '',
|
||||
isDiffNewField
|
||||
? 'text-green-800 dark:text-green-200'
|
||||
: '',
|
||||
isDiffFieldChanged &&
|
||||
!isSummaryOnly &&
|
||||
!isDiffFieldRemoved &&
|
||||
!isDiffNewField
|
||||
? 'text-sky-800 dark:text-sky-200'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<KeyRound size={14} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="ml-2 flex shrink-0 items-center justify-end gap-1.5">
|
||||
{(field.primaryKey &&
|
||||
fieldDiffChangedPrimaryKey === null) ||
|
||||
fieldDiffChangedPrimaryKey ? (
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground',
|
||||
'content-center text-right text-xs text-muted-foreground overflow-hidden max-w-[8rem]',
|
||||
field.primaryKey ? 'min-w-0' : 'min-w-[3rem]',
|
||||
!readonly ? 'group-hover:hidden' : '',
|
||||
isDiffFieldRemoved
|
||||
? 'text-red-800 dark:text-red-200'
|
||||
@@ -403,92 +467,75 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
|
||||
? 'text-green-800 dark:text-green-200'
|
||||
: '',
|
||||
isDiffFieldChanged &&
|
||||
!isSummaryOnly &&
|
||||
!isDiffFieldRemoved &&
|
||||
!isSummaryOnly &&
|
||||
!isDiffNewField
|
||||
? 'text-sky-800 dark:text-sky-200'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<KeyRound size={14} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'content-center text-right text-xs text-muted-foreground overflow-hidden max-w-[8rem]',
|
||||
field.primaryKey ? 'min-w-0' : 'min-w-[3rem]',
|
||||
!readonly ? 'group-hover:hidden' : '',
|
||||
isDiffFieldRemoved
|
||||
? 'text-red-800 dark:text-red-200'
|
||||
: '',
|
||||
isDiffNewField
|
||||
? 'text-green-800 dark:text-green-200'
|
||||
: '',
|
||||
isDiffFieldChanged &&
|
||||
!isDiffFieldRemoved &&
|
||||
!isSummaryOnly &&
|
||||
!isDiffNewField
|
||||
? 'text-sky-800 dark:text-sky-200'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<span className="block truncate">
|
||||
{fieldDiffChangedType ? (
|
||||
<>
|
||||
<span className="line-through">
|
||||
{field.type.name.split(' ')[0]}
|
||||
</span>{' '}
|
||||
{fieldDiffChangedType.name.split(' ')[0]}
|
||||
</>
|
||||
) : (
|
||||
`${field.type.name.split(' ')[0]}${
|
||||
showFieldAttributes
|
||||
? generateDBFieldSuffix({
|
||||
...field,
|
||||
...{
|
||||
precision:
|
||||
fieldDiffChangedPrecision ??
|
||||
field.precision,
|
||||
scale:
|
||||
fieldDiffChangedScale ??
|
||||
field.scale,
|
||||
characterMaximumLength:
|
||||
fieldDiffChangedCharacterMaximumLength ??
|
||||
field.characterMaximumLength,
|
||||
},
|
||||
})
|
||||
: ''
|
||||
}`
|
||||
)}
|
||||
{fieldDiffChangedNullable !== null ? (
|
||||
fieldDiffChangedNullable ? (
|
||||
<span className="font-semibold">?</span>
|
||||
<span className="block truncate">
|
||||
{fieldDiffChangedType ? (
|
||||
<>
|
||||
<span className="line-through">
|
||||
{field.type.name.split(' ')[0]}
|
||||
</span>{' '}
|
||||
{
|
||||
fieldDiffChangedType.name.split(
|
||||
' '
|
||||
)[0]
|
||||
}
|
||||
</>
|
||||
) : (
|
||||
<span className="line-through">?</span>
|
||||
)
|
||||
) : field.nullable ? (
|
||||
'?'
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{readonly ? null : (
|
||||
<div className="hidden flex-row group-hover:flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-6 p-0 hover:bg-primary-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEditTableOnField();
|
||||
}}
|
||||
>
|
||||
<Pencil className="!size-3.5 text-pink-600" />
|
||||
</Button>
|
||||
`${field.type.name.split(' ')[0]}${
|
||||
showFieldAttributes
|
||||
? generateDBFieldSuffix({
|
||||
...field,
|
||||
...{
|
||||
precision:
|
||||
fieldDiffChangedPrecision ??
|
||||
field.precision,
|
||||
scale:
|
||||
fieldDiffChangedScale ??
|
||||
field.scale,
|
||||
characterMaximumLength:
|
||||
fieldDiffChangedCharacterMaximumLength ??
|
||||
field.characterMaximumLength,
|
||||
},
|
||||
})
|
||||
: ''
|
||||
}`
|
||||
)}
|
||||
{fieldDiffChangedNullable !== null ? (
|
||||
fieldDiffChangedNullable ? (
|
||||
<span className="font-semibold">?</span>
|
||||
) : (
|
||||
<span className="line-through">?</span>
|
||||
)
|
||||
) : field.nullable ? (
|
||||
'?'
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!readonly && onOpenEditMode ? (
|
||||
<div className="hidden items-center group-hover:flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-6 p-0.5 hover:bg-slate-200 dark:hover:bg-slate-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onOpenEditMode();
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3.5 text-slate-600 dark:text-slate-400" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -6,7 +6,12 @@ import React, {
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import type { NodeProps, Node } from '@xyflow/react';
|
||||
import { NodeResizer, useConnection, useStore } from '@xyflow/react';
|
||||
import {
|
||||
NodeResizer,
|
||||
useConnection,
|
||||
useStore,
|
||||
useReactFlow,
|
||||
} from '@xyflow/react';
|
||||
import { Button } from '@/components/button/button';
|
||||
import {
|
||||
ChevronsLeftRight,
|
||||
@@ -14,6 +19,7 @@ import {
|
||||
Table2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Check,
|
||||
CircleDotDashed,
|
||||
SquareDot,
|
||||
SquarePlus,
|
||||
@@ -37,6 +43,8 @@ import { TableNodeContextMenu } from './table-node-context-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TableNodeDependencyIndicator } from './table-node-dependency-indicator';
|
||||
import type { EdgeType } from '../canvas';
|
||||
import { Input } from '@/components/input/input';
|
||||
import { useClickAway, useKeyPressEvent } from 'react-use';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -44,8 +52,7 @@ import {
|
||||
} from '@/components/tooltip/tooltip';
|
||||
import { useDiff } from '@/context/diff-context/use-diff';
|
||||
import { TableNodeStatus } from './table-node-status/table-node-status';
|
||||
import { TableEditMode } from './table-edit-mode/table-edit-mode';
|
||||
import { useCanvas } from '@/hooks/use-canvas';
|
||||
import { TableEditMode } from './table-edit-mode';
|
||||
|
||||
export type TableNodeType = Node<
|
||||
{
|
||||
@@ -80,25 +87,33 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
|
||||
} = useLayout();
|
||||
const [expanded, setExpanded] = useState(table.expanded ?? false);
|
||||
const { t } = useTranslation();
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [tableName, setTableName] = useState(table.name);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const { setEditTableModeTable, editTableModeTable } = useCanvas();
|
||||
|
||||
// Get edit mode state directly from context
|
||||
const editTableMode = useMemo(
|
||||
() => editTableModeTable?.tableId === table.id,
|
||||
[editTableModeTable, table.id]
|
||||
const [isTableEditMode, setIsTableEditMode] = useState(false);
|
||||
const [focusFieldId, setFocusFieldId] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
const editTableModeFieldId = useMemo(
|
||||
() => (editTableMode ? editTableModeTable?.fieldId : null),
|
||||
[editTableMode, editTableModeTable]
|
||||
);
|
||||
|
||||
// Store the initial field count when entering edit mode to keep table height fixed
|
||||
const [editModeInitialFieldCount, setEditModeInitialFieldCount] =
|
||||
useState<number | null>(null);
|
||||
const { setNodes } = useReactFlow();
|
||||
|
||||
const connection = useConnection();
|
||||
|
||||
// Update node draggable state when edit mode changes
|
||||
useEffect(() => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) => {
|
||||
if (node.id === id) {
|
||||
return {
|
||||
...node,
|
||||
draggable: !isTableEditMode,
|
||||
};
|
||||
}
|
||||
return node;
|
||||
})
|
||||
);
|
||||
}, [isTableEditMode, id, setNodes]);
|
||||
|
||||
const isTarget = useMemo(() => {
|
||||
if (!isHovering) return false;
|
||||
|
||||
@@ -116,17 +131,6 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
|
||||
|
||||
const fields = useMemo(() => table.fields, [table.fields]);
|
||||
|
||||
// Effect to manage field count when entering/exiting edit mode
|
||||
useEffect(() => {
|
||||
if (editTableMode && editModeInitialFieldCount === null) {
|
||||
// Entering edit mode - capture current field count
|
||||
setEditModeInitialFieldCount(fields.length);
|
||||
} else if (!editTableMode && editModeInitialFieldCount !== null) {
|
||||
// Exiting edit mode - reset
|
||||
setEditModeInitialFieldCount(null);
|
||||
}
|
||||
}, [editTableMode, fields.length, editModeInitialFieldCount]);
|
||||
|
||||
const tableChangedName = useMemo(
|
||||
() => getTableNewName({ tableId: table.id }),
|
||||
[getTableNewName, table.id]
|
||||
@@ -261,20 +265,14 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
|
||||
}, [relationships]);
|
||||
|
||||
const visibleFields = useMemo(() => {
|
||||
// If in edit mode, use the initial field count to keep consistent height
|
||||
const fieldsToConsider =
|
||||
editTableMode && editModeInitialFieldCount !== null
|
||||
? fields.slice(0, editModeInitialFieldCount)
|
||||
: fields;
|
||||
|
||||
if (expanded || fieldsToConsider.length <= TABLE_MINIMIZED_FIELDS) {
|
||||
return fieldsToConsider;
|
||||
if (expanded || fields.length <= TABLE_MINIMIZED_FIELDS) {
|
||||
return fields;
|
||||
}
|
||||
|
||||
const mustDisplayedFields: DBField[] = [];
|
||||
const nonMustDisplayedFields: DBField[] = [];
|
||||
|
||||
for (const field of fieldsToConsider) {
|
||||
for (const field of fields) {
|
||||
if (relatedFieldIds.has(field.id) || field.primaryKey) {
|
||||
mustDisplayedFields.push(field);
|
||||
} else {
|
||||
@@ -301,18 +299,40 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
|
||||
...visibleMustDisplayedFields,
|
||||
...visibleNonMustDisplayedFields,
|
||||
]);
|
||||
const result = fieldsToConsider.filter((field) =>
|
||||
const result = fields.filter((field) =>
|
||||
visibleFieldsSet.has(field)
|
||||
);
|
||||
|
||||
return result;
|
||||
}, [
|
||||
expanded,
|
||||
fields,
|
||||
relatedFieldIds,
|
||||
editTableMode,
|
||||
editModeInitialFieldCount,
|
||||
]);
|
||||
}, [expanded, fields, relatedFieldIds]);
|
||||
|
||||
const editTableName = useCallback(() => {
|
||||
if (!editMode) return;
|
||||
if (tableName.trim()) {
|
||||
updateTable(table.id, { name: tableName.trim() });
|
||||
}
|
||||
setEditMode(false);
|
||||
}, [tableName, table.id, updateTable, editMode]);
|
||||
|
||||
const abortEdit = useCallback(() => {
|
||||
setEditMode(false);
|
||||
setTableName(table.name);
|
||||
}, [table.name]);
|
||||
|
||||
useClickAway(inputRef, editTableName);
|
||||
useKeyPressEvent('Enter', editTableName);
|
||||
useKeyPressEvent('Escape', abortEdit);
|
||||
|
||||
const enterEditMode = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditMode(true);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (table.name.trim()) {
|
||||
setTableName(table.name.trim());
|
||||
}
|
||||
}, [table.name]);
|
||||
|
||||
const tableClassName = useMemo(
|
||||
() =>
|
||||
@@ -347,9 +367,8 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
|
||||
: '',
|
||||
isDiffTableRemoved
|
||||
? 'outline outline-[3px] outline-red-500 dark:outline-red-900 outline-offset-[5px]'
|
||||
: editTableMode
|
||||
? 'invisible'
|
||||
: ''
|
||||
: '',
|
||||
isTableEditMode ? 'cursor-default' : ''
|
||||
),
|
||||
[
|
||||
selected,
|
||||
@@ -362,213 +381,286 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
|
||||
isDiffNewTable,
|
||||
isDiffTableRemoved,
|
||||
isTarget,
|
||||
editTableMode,
|
||||
isTableEditMode,
|
||||
]
|
||||
);
|
||||
|
||||
const enterEditTableMode = useCallback(() => {
|
||||
if (readonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeAllTablesInSidebar();
|
||||
setEditTableModeTable({ tableId: table.id });
|
||||
}, [
|
||||
table.id,
|
||||
setEditTableModeTable,
|
||||
closeAllTablesInSidebar,
|
||||
readonly,
|
||||
]);
|
||||
|
||||
const exitEditTableMode = useCallback(() => {
|
||||
setEditTableModeTable(null);
|
||||
}, [setEditTableModeTable]);
|
||||
|
||||
return (
|
||||
<TableNodeContextMenu table={table}>
|
||||
{editTableMode ? (
|
||||
<TableEditMode
|
||||
table={table}
|
||||
color={tableColor}
|
||||
focusFieldId={editTableModeFieldId ?? undefined}
|
||||
onClose={() => {
|
||||
exitEditTableMode();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={tableClassName}
|
||||
onClick={(e) => {
|
||||
if (e.detail === 2 && !readonly) {
|
||||
enterEditTableMode();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsTableEditMode(true);
|
||||
// Close sidebar after a short delay to avoid blocking the UI
|
||||
setTimeout(() => {
|
||||
closeAllTablesInSidebar();
|
||||
}, 50);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
<NodeResizer
|
||||
isVisible={focused}
|
||||
lineClassName="!border-none !w-2"
|
||||
minWidth={MIN_TABLE_SIZE}
|
||||
maxWidth={MAX_TABLE_SIZE}
|
||||
shouldResize={(event) => event.dy === 0}
|
||||
handleClassName="!hidden"
|
||||
/>
|
||||
<TableNodeDependencyIndicator
|
||||
table={table}
|
||||
focused={focused}
|
||||
/>
|
||||
<TableNodeStatus
|
||||
status={
|
||||
isDiffNewTable
|
||||
? 'new'
|
||||
: isDiffTableRemoved
|
||||
? 'removed'
|
||||
: isDiffTableChanged && !isSummaryOnly
|
||||
? 'changed'
|
||||
: 'none'
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="h-2 rounded-t-[6px]"
|
||||
style={{ backgroundColor: tableColor }}
|
||||
></div>
|
||||
<div className="group flex h-9 items-center justify-between bg-slate-200 px-2 dark:bg-slate-900">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{isDiffNewTable ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SquarePlus
|
||||
className="size-3.5 shrink-0 text-green-600"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>New Table</TooltipContent>
|
||||
</Tooltip>
|
||||
) : isDiffTableRemoved ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SquareMinus
|
||||
className="size-3.5 shrink-0 text-red-600"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Table Removed
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : isDiffTableChanged && !isSummaryOnly ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SquareDot
|
||||
className="size-3.5 shrink-0 text-sky-600"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Table Changed
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Table2 className="size-3.5 shrink-0 text-gray-600 dark:text-primary" />
|
||||
)}
|
||||
|
||||
{tableChangedName ? (
|
||||
<Label className="flex h-5 items-center justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200">
|
||||
<span className="truncate">
|
||||
{table.name}
|
||||
</span>
|
||||
<span className="mx-1 font-semibold">
|
||||
→
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{tableChangedName}
|
||||
</span>
|
||||
</Label>
|
||||
) : isDiffNewTable ? (
|
||||
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-green-200 px-2 py-0.5 text-sm font-normal text-green-900 dark:bg-green-800 dark:text-green-200">
|
||||
{table.name}
|
||||
</Label>
|
||||
) : isDiffTableRemoved ? (
|
||||
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-red-200 px-2 py-0.5 text-sm font-normal text-red-900 dark:bg-red-800 dark:text-red-200">
|
||||
{table.name}
|
||||
</Label>
|
||||
) : isDiffTableChanged && !isSummaryOnly ? (
|
||||
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200">
|
||||
{table.name}
|
||||
</Label>
|
||||
) : (
|
||||
<Label className="truncate px-2 py-0.5 text-sm font-bold">
|
||||
{table.name}
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden shrink-0 flex-row group-hover:flex">
|
||||
{readonly ? null : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||||
onClick={openTableInEditor}
|
||||
>
|
||||
<CircleDotDashed className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||||
onClick={
|
||||
table.width !== MAX_TABLE_SIZE
|
||||
? expandTable
|
||||
: shrinkTable
|
||||
}
|
||||
{/* Keep minimal table structure for connections even in edit mode */}
|
||||
{isTableEditMode && (
|
||||
<>
|
||||
{/* Hidden fields to maintain connection handles */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{table.width !== MAX_TABLE_SIZE ? (
|
||||
<ChevronsLeftRight className="size-4" />
|
||||
) : (
|
||||
<ChevronsRightLeft className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="transition-[max-height] duration-200 ease-in-out"
|
||||
style={{
|
||||
maxHeight: expanded
|
||||
? `${(editTableMode && editModeInitialFieldCount !== null ? editModeInitialFieldCount : fields.length) * 2}rem` // h-8 per field
|
||||
: `${TABLE_MINIMIZED_FIELDS * 2}rem`, // h-8 per field
|
||||
}}
|
||||
>
|
||||
{visibleFields.map((field: DBField) => (
|
||||
<TableNodeField
|
||||
key={field.id}
|
||||
focused={focused}
|
||||
tableNodeId={id}
|
||||
field={field}
|
||||
highlighted={highlightedFieldIds.has(field.id)}
|
||||
visible={true}
|
||||
isConnectable={!table.isView}
|
||||
{table.fields.map((field: DBField) => (
|
||||
<TableNodeField
|
||||
key={field.id}
|
||||
focused={false}
|
||||
tableNodeId={id}
|
||||
field={field}
|
||||
highlighted={false}
|
||||
visible={false}
|
||||
isConnectable={!table.isView}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<TableEditMode
|
||||
table={table}
|
||||
color={tableColor}
|
||||
focusFieldId={focusFieldId}
|
||||
onClose={() => {
|
||||
setIsTableEditMode(false);
|
||||
setFocusFieldId(undefined);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{(editTableMode && editModeInitialFieldCount !== null
|
||||
? editModeInitialFieldCount
|
||||
: fields.length) > TABLE_MINIMIZED_FIELDS && (
|
||||
<div
|
||||
className="z-10 flex h-8 cursor-pointer items-center justify-center rounded-b-md border-t text-xs text-muted-foreground transition-colors duration-200 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
onClick={toggleExpand}
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp className="mr-1 size-3.5" />
|
||||
{t('show_less')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="mr-1 size-3.5" />
|
||||
{t('show_more')}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isTableEditMode && (
|
||||
<>
|
||||
{focused ? (
|
||||
<NodeResizer
|
||||
isVisible={focused}
|
||||
lineClassName="!border-none !w-2"
|
||||
minWidth={MIN_TABLE_SIZE}
|
||||
maxWidth={MAX_TABLE_SIZE}
|
||||
shouldResize={(event) => event.dy === 0}
|
||||
handleClassName="!hidden"
|
||||
/>
|
||||
) : null}
|
||||
<TableNodeDependencyIndicator
|
||||
table={table}
|
||||
focused={focused}
|
||||
/>
|
||||
<TableNodeStatus
|
||||
status={
|
||||
isDiffNewTable
|
||||
? 'new'
|
||||
: isDiffTableRemoved
|
||||
? 'removed'
|
||||
: isDiffTableChanged && !isSummaryOnly
|
||||
? 'changed'
|
||||
: 'none'
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="h-2 rounded-t-[6px]"
|
||||
style={{ backgroundColor: tableColor }}
|
||||
></div>
|
||||
<div className="group flex h-9 items-center justify-between bg-slate-200 px-2 dark:bg-slate-900">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{isDiffNewTable ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SquarePlus
|
||||
className="size-3.5 shrink-0 text-green-600"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
New Table
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : isDiffTableRemoved ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SquareMinus
|
||||
className="size-3.5 shrink-0 text-red-600"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Table Removed
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : isDiffTableChanged && !isSummaryOnly ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SquareDot
|
||||
className="size-3.5 shrink-0 text-sky-600"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Table Changed
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Table2 className="size-3.5 shrink-0 text-gray-600 dark:text-primary" />
|
||||
)}
|
||||
|
||||
{tableChangedName ? (
|
||||
<Label className="flex h-5 items-center justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200">
|
||||
<span className="truncate">
|
||||
{table.name}
|
||||
</span>
|
||||
<span className="mx-1 font-semibold">
|
||||
→
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{tableChangedName}
|
||||
</span>
|
||||
</Label>
|
||||
) : isDiffNewTable ? (
|
||||
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-green-200 px-2 py-0.5 text-sm font-normal text-green-900 dark:bg-green-800 dark:text-green-200">
|
||||
{table.name}
|
||||
</Label>
|
||||
) : isDiffTableRemoved ? (
|
||||
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-red-200 px-2 py-0.5 text-sm font-normal text-red-900 dark:bg-red-800 dark:text-red-200">
|
||||
{table.name}
|
||||
</Label>
|
||||
) : isDiffTableChanged && !isSummaryOnly ? (
|
||||
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200">
|
||||
{table.name}
|
||||
</Label>
|
||||
) : editMode && !readonly ? (
|
||||
<>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
onBlur={editTableName}
|
||||
placeholder={table.name}
|
||||
autoFocus
|
||||
type="text"
|
||||
value={tableName}
|
||||
onClick={(e) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
onChange={(e) =>
|
||||
setTableName(e.target.value)
|
||||
}
|
||||
className="h-6 w-full border-[0.5px] border-blue-400 bg-slate-100 focus-visible:ring-0 dark:bg-slate-900"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||||
onClick={editTableName}
|
||||
>
|
||||
<Check className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Label
|
||||
className="text-editable truncate px-2 py-0.5 text-sm font-bold"
|
||||
onDoubleClick={
|
||||
enterEditMode
|
||||
}
|
||||
>
|
||||
{table.name}
|
||||
</Label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t(
|
||||
'tool_tips.double_click_to_edit'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden shrink-0 flex-row group-hover:flex">
|
||||
{readonly || editMode || !focused ? null : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||||
onClick={openTableInEditor}
|
||||
>
|
||||
<CircleDotDashed className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{editMode || !focused ? null : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||||
onClick={
|
||||
table.width !== MAX_TABLE_SIZE
|
||||
? expandTable
|
||||
: shrinkTable
|
||||
}
|
||||
>
|
||||
{table.width !== MAX_TABLE_SIZE ? (
|
||||
<ChevronsLeftRight className="size-4" />
|
||||
) : (
|
||||
<ChevronsRightLeft className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
className="transition-[max-height] duration-200 ease-in-out"
|
||||
style={{
|
||||
maxHeight: expanded
|
||||
? `${fields.length * 2}rem` // h-8 per field
|
||||
: `${TABLE_MINIMIZED_FIELDS * 2}rem`, // h-8 per field
|
||||
}}
|
||||
>
|
||||
{visibleFields.map((field: DBField) => (
|
||||
<TableNodeField
|
||||
key={field.id}
|
||||
focused={focused}
|
||||
tableNodeId={id}
|
||||
field={field}
|
||||
highlighted={highlightedFieldIds.has(
|
||||
field.id
|
||||
)}
|
||||
visible={true}
|
||||
isConnectable={!table.isView}
|
||||
onOpenEditMode={() => {
|
||||
if (!readonly) {
|
||||
setFocusFieldId(field.id);
|
||||
setIsTableEditMode(true);
|
||||
setTimeout(() => {
|
||||
closeAllTablesInSidebar();
|
||||
}, 50);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{fields.length > TABLE_MINIMIZED_FIELDS && (
|
||||
<div
|
||||
className="z-10 flex h-8 cursor-pointer items-center justify-center rounded-b-md border-t text-xs text-muted-foreground transition-colors duration-200 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
onClick={toggleExpand}
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp className="mr-1 size-3.5" />
|
||||
{t('show_less')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="mr-1 size-3.5" />
|
||||
{t('show_more')}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableNodeContextMenu>
|
||||
|
||||
@@ -11,8 +11,7 @@ import type { DBRelationship } from '@/lib/domain/db-relationship';
|
||||
import { useReactFlow } from '@xyflow/react';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import { useFocusOn } from '@/hooks/use-focus-on';
|
||||
import { useEditClickOutside } from '@/hooks/use-click-outside';
|
||||
import { useKeyPressEvent } from 'react-use';
|
||||
import { useClickAway, useKeyPressEvent } from 'react-use';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -43,37 +42,31 @@ export const RelationshipListItemHeader: React.FC<
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const editRelationshipName = useCallback(() => {
|
||||
if (!editMode) return;
|
||||
if (relationshipName.trim() && relationshipName !== relationship.name) {
|
||||
updateRelationship(relationship.id, {
|
||||
name: relationshipName.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
setEditMode(false);
|
||||
}, [
|
||||
relationshipName,
|
||||
relationship.id,
|
||||
updateRelationship,
|
||||
editMode,
|
||||
relationship.name,
|
||||
]);
|
||||
|
||||
const abortEdit = useCallback(() => {
|
||||
setEditMode(false);
|
||||
setRelationshipName(relationship.name);
|
||||
}, [relationship.name]);
|
||||
|
||||
// Handle click outside to save and exit edit mode
|
||||
useEditClickOutside(inputRef, editMode, editRelationshipName);
|
||||
useClickAway(inputRef, editRelationshipName);
|
||||
useKeyPressEvent('Enter', editRelationshipName);
|
||||
useKeyPressEvent('Escape', abortEdit);
|
||||
|
||||
const enterEditMode = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
event.stopPropagation();
|
||||
setRelationshipName(relationship.name);
|
||||
setEditMode(true);
|
||||
},
|
||||
[relationship.name]
|
||||
);
|
||||
const enterEditMode = (
|
||||
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
setEditMode(true);
|
||||
};
|
||||
|
||||
const handleFocusOnRelationship = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { GripVertical, KeyRound } from 'lucide-react';
|
||||
import { Input } from '@/components/input/input';
|
||||
import { generateDBFieldSuffix, type DBField } from '@/lib/domain/db-field';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import { useUpdateTableField } from '@/hooks/use-update-table-field';
|
||||
import type { DataTypeData } from '@/lib/data/data-types/data-types';
|
||||
import {
|
||||
dataTypeDataToDataType,
|
||||
sortedDataTypeMap,
|
||||
} from '@/lib/data/data-types/data-types';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -13,6 +17,10 @@ import { useTranslation } from 'react-i18next';
|
||||
import { TableFieldToggle } from './table-field-toggle';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import type {
|
||||
SelectBoxOption,
|
||||
SelectBoxProps,
|
||||
} from '@/components/select-box/select-box';
|
||||
import { SelectBox } from '@/components/select-box/select-box';
|
||||
import { TableFieldPopover } from './table-field-modal/table-field-modal';
|
||||
import type { DBTable } from '@/lib/domain';
|
||||
@@ -24,35 +32,213 @@ export interface TableFieldProps {
|
||||
removeField: () => void;
|
||||
}
|
||||
|
||||
const generateFieldRegexPatterns = (
|
||||
dataType: DataTypeData
|
||||
): {
|
||||
regex?: string;
|
||||
extractRegex?: RegExp;
|
||||
} => {
|
||||
if (!dataType.fieldAttributes) {
|
||||
return { regex: undefined, extractRegex: undefined };
|
||||
}
|
||||
|
||||
const typeName = dataType.name;
|
||||
const fieldAttributes = dataType.fieldAttributes;
|
||||
|
||||
if (fieldAttributes.hasCharMaxLength) {
|
||||
if (fieldAttributes.hasCharMaxLengthOption) {
|
||||
return {
|
||||
regex: `^${typeName}\\((\\d+|[mM][aA][xX])\\)$`,
|
||||
extractRegex: /\((\d+|max)\)/i,
|
||||
};
|
||||
}
|
||||
return {
|
||||
regex: `^${typeName}\\(\\d+\\)$`,
|
||||
extractRegex: /\((\d+)\)/,
|
||||
};
|
||||
}
|
||||
|
||||
if (fieldAttributes.precision && fieldAttributes.scale) {
|
||||
return {
|
||||
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*(?:,\\s*\\d+\\s*)?\\)$`,
|
||||
extractRegex: new RegExp(
|
||||
`${typeName}\\s*\\(\\s*(\\d+)\\s*(?:,\\s*(\\d+)\\s*)?\\)`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (fieldAttributes.precision) {
|
||||
return {
|
||||
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*\\)$`,
|
||||
extractRegex: /\((\d+)\)/,
|
||||
};
|
||||
}
|
||||
|
||||
return { regex: undefined, extractRegex: undefined };
|
||||
};
|
||||
|
||||
export const TableField: React.FC<TableFieldProps> = ({
|
||||
table,
|
||||
field,
|
||||
updateField,
|
||||
removeField,
|
||||
}) => {
|
||||
const { databaseType, readonly } = useChartDB();
|
||||
const { databaseType, customTypes, readonly } = useChartDB();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Only calculate primary key fields, not just count
|
||||
const primaryKeyFields = useMemo(() => {
|
||||
return table.fields.filter((f) => f.primaryKey);
|
||||
}, [table.fields]);
|
||||
|
||||
const primaryKeyCount = primaryKeyFields.length;
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: field.id });
|
||||
|
||||
const {
|
||||
dataFieldOptions,
|
||||
handleDataTypeChange,
|
||||
handlePrimaryKeyToggle,
|
||||
handleNullableToggle,
|
||||
handleNameChange,
|
||||
generateFieldSuffix,
|
||||
fieldName,
|
||||
nullable,
|
||||
primaryKey,
|
||||
} = useUpdateTableField(table, field, updateField);
|
||||
const dataFieldOptions = useMemo(() => {
|
||||
const standardTypes: SelectBoxOption[] = sortedDataTypeMap[
|
||||
databaseType
|
||||
].map((type) => {
|
||||
const regexPatterns = generateFieldRegexPatterns(type);
|
||||
|
||||
return {
|
||||
label: type.name,
|
||||
value: type.id,
|
||||
regex: regexPatterns.regex,
|
||||
extractRegex: regexPatterns.extractRegex,
|
||||
group: customTypes?.length ? 'Standard Types' : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
if (!customTypes?.length) {
|
||||
return standardTypes;
|
||||
}
|
||||
|
||||
// Add custom types as options
|
||||
const customTypeOptions: SelectBoxOption[] = customTypes.map(
|
||||
(type) => ({
|
||||
label: type.name,
|
||||
value: type.name,
|
||||
description:
|
||||
type.kind === 'enum' ? `${type.values?.join(' | ')}` : '',
|
||||
group: 'Custom Types',
|
||||
})
|
||||
);
|
||||
|
||||
return [...standardTypes, ...customTypeOptions];
|
||||
}, [databaseType, customTypes]);
|
||||
|
||||
const onChangeDataType = useCallback<
|
||||
NonNullable<SelectBoxProps['onChange']>
|
||||
>(
|
||||
(value, regexMatches) => {
|
||||
const dataType = sortedDataTypeMap[databaseType].find(
|
||||
(v) => v.id === value
|
||||
) ?? {
|
||||
id: value as string,
|
||||
name: value as string,
|
||||
};
|
||||
|
||||
let characterMaximumLength: string | undefined = undefined;
|
||||
let precision: number | undefined = undefined;
|
||||
let scale: number | undefined = undefined;
|
||||
|
||||
if (regexMatches?.length) {
|
||||
if (dataType?.fieldAttributes?.hasCharMaxLength) {
|
||||
characterMaximumLength = regexMatches[1]?.toLowerCase();
|
||||
} else if (
|
||||
dataType?.fieldAttributes?.precision &&
|
||||
dataType?.fieldAttributes?.scale
|
||||
) {
|
||||
precision = parseInt(regexMatches[1]);
|
||||
scale = regexMatches[2]
|
||||
? parseInt(regexMatches[2])
|
||||
: undefined;
|
||||
} else if (dataType?.fieldAttributes?.precision) {
|
||||
precision = parseInt(regexMatches[1]);
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
dataType?.fieldAttributes?.hasCharMaxLength &&
|
||||
field.characterMaximumLength
|
||||
) {
|
||||
characterMaximumLength = field.characterMaximumLength;
|
||||
}
|
||||
|
||||
if (dataType?.fieldAttributes?.precision && field.precision) {
|
||||
precision = field.precision;
|
||||
}
|
||||
|
||||
if (dataType?.fieldAttributes?.scale && field.scale) {
|
||||
scale = field.scale;
|
||||
}
|
||||
}
|
||||
|
||||
updateField({
|
||||
characterMaximumLength,
|
||||
precision,
|
||||
scale,
|
||||
increment: undefined,
|
||||
default: undefined,
|
||||
type: dataTypeDataToDataType(
|
||||
dataType ?? {
|
||||
id: value as string,
|
||||
name: value as string,
|
||||
}
|
||||
),
|
||||
});
|
||||
},
|
||||
[
|
||||
updateField,
|
||||
databaseType,
|
||||
field.characterMaximumLength,
|
||||
field.precision,
|
||||
field.scale,
|
||||
]
|
||||
);
|
||||
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const handlePrimaryKeyToggle = useCallback(
|
||||
(value: boolean) => {
|
||||
if (value) {
|
||||
// When setting as primary key
|
||||
const updates: Partial<DBField> = {
|
||||
primaryKey: true,
|
||||
};
|
||||
// Only auto-set unique if this will be the only primary key
|
||||
if (primaryKeyCount === 0) {
|
||||
updates.unique = true;
|
||||
}
|
||||
updateField(updates);
|
||||
} else {
|
||||
// When removing primary key
|
||||
updateField({
|
||||
primaryKey: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[primaryKeyCount, updateField]
|
||||
);
|
||||
|
||||
const handleNullableToggle = useCallback(
|
||||
(value: boolean) => {
|
||||
updateField({ nullable: value });
|
||||
},
|
||||
[updateField]
|
||||
);
|
||||
|
||||
const handleNameChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateField({ name: e.target.value });
|
||||
},
|
||||
[updateField]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-1 touch-none flex-row justify-between gap-2 p-1"
|
||||
@@ -78,10 +264,8 @@ export const TableField: React.FC<TableFieldProps> = ({
|
||||
placeholder={t(
|
||||
'side_panel.tables_section.table.field_name'
|
||||
)}
|
||||
value={fieldName}
|
||||
onChange={(e) =>
|
||||
handleNameChange(e.target.value)
|
||||
}
|
||||
value={field.name}
|
||||
onChange={handleNameChange}
|
||||
readOnly={readonly}
|
||||
/>
|
||||
</span>
|
||||
@@ -101,9 +285,13 @@ export const TableField: React.FC<TableFieldProps> = ({
|
||||
value={field.type.id}
|
||||
valueSuffix={generateDBFieldSuffix(field)}
|
||||
optionSuffix={(option) =>
|
||||
generateFieldSuffix(option.value)
|
||||
generateDBFieldSuffix(field, {
|
||||
databaseType,
|
||||
forceExtended: true,
|
||||
typeId: option.value,
|
||||
})
|
||||
}
|
||||
onChange={handleDataTypeChange}
|
||||
onChange={onChangeDataType}
|
||||
emptyPlaceholder={t(
|
||||
'side_panel.tables_section.table.no_types_found'
|
||||
)}
|
||||
@@ -124,7 +312,7 @@ export const TableField: React.FC<TableFieldProps> = ({
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<TableFieldToggle
|
||||
pressed={nullable}
|
||||
pressed={field.nullable}
|
||||
onPressedChange={handleNullableToggle}
|
||||
disabled={readonly}
|
||||
>
|
||||
@@ -140,7 +328,7 @@ export const TableField: React.FC<TableFieldProps> = ({
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<TableFieldToggle
|
||||
pressed={primaryKey}
|
||||
pressed={field.primaryKey}
|
||||
onPressedChange={handlePrimaryKeyToggle}
|
||||
disabled={readonly}
|
||||
>
|
||||
|
||||
@@ -15,8 +15,7 @@ import { ListItemHeaderButton } from '@/pages/editor-page/side-panel/list-item-h
|
||||
import type { DBTable } from '@/lib/domain/db-table';
|
||||
import { Input } from '@/components/input/input';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import { useEditClickOutside } from '@/hooks/use-click-outside';
|
||||
import { useKeyPressEvent } from 'react-use';
|
||||
import { useClickAway, useKeyPressEvent } from 'react-use';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -68,30 +67,27 @@ export const TableListItemHeader: React.FC<TableListItemHeaderProps> = ({
|
||||
const { listeners } = useSortable({ id: table.id });
|
||||
|
||||
const editTableName = useCallback(() => {
|
||||
if (!editMode) return;
|
||||
if (tableName.trim()) {
|
||||
updateTable(table.id, { name: tableName.trim() });
|
||||
}
|
||||
|
||||
setEditMode(false);
|
||||
}, [tableName, table.id, updateTable]);
|
||||
}, [tableName, table.id, updateTable, editMode]);
|
||||
|
||||
const abortEdit = useCallback(() => {
|
||||
setEditMode(false);
|
||||
setTableName(table.name);
|
||||
}, [table.name]);
|
||||
|
||||
// Handle click outside to save and exit edit mode
|
||||
useEditClickOutside(inputRef, editMode, editTableName);
|
||||
useClickAway(inputRef, editTableName);
|
||||
useKeyPressEvent('Enter', editTableName);
|
||||
useKeyPressEvent('Escape', abortEdit);
|
||||
|
||||
const enterEditMode = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setTableName(table.name);
|
||||
setEditMode(true);
|
||||
},
|
||||
[table.name]
|
||||
);
|
||||
const enterEditMode = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditMode(true);
|
||||
};
|
||||
|
||||
const handleFocusOnTable = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
@@ -253,20 +249,6 @@ export const TableListItemHeader: React.FC<TableListItemHeaderProps> = ({
|
||||
}
|
||||
}, [table.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editMode) {
|
||||
// Small delay to ensure the input is rendered
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
return (
|
||||
<div className="group flex h-11 flex-1 items-center justify-between gap-1 overflow-hidden">
|
||||
{!readonly ? (
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useEditClickOutside } from '@/hooks/use-click-outside';
|
||||
import { Button } from '@/components/button/button';
|
||||
import { Check, Pencil } from 'lucide-react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Input } from '@/components/input/input';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import { useKeyPressEvent } from 'react-use';
|
||||
import { useClickAway, useKeyPressEvent } from 'react-use';
|
||||
import { DiagramIcon } from '@/components/diagram-icon/diagram-icon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -32,45 +31,23 @@ export const DiagramName: React.FC<DiagramNameProps> = () => {
|
||||
setEditedDiagramName(diagramName);
|
||||
}, [diagramName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editMode) {
|
||||
// Small delay to ensure the input is rendered
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, 50); // Slightly longer delay to ensure DOM is ready
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
const editDiagramName = useCallback(() => {
|
||||
if (!editMode) return;
|
||||
if (editedDiagramName.trim()) {
|
||||
updateDiagramName(editedDiagramName.trim());
|
||||
}
|
||||
setEditMode(false);
|
||||
}, [editedDiagramName, updateDiagramName]);
|
||||
}, [editedDiagramName, updateDiagramName, editMode]);
|
||||
|
||||
const abortEdit = useCallback(() => {
|
||||
setEditMode(false);
|
||||
setEditedDiagramName(diagramName);
|
||||
}, [diagramName]);
|
||||
|
||||
// Handle click outside to save and exit edit mode
|
||||
useEditClickOutside(inputRef, editMode, editDiagramName);
|
||||
useClickAway(inputRef, editDiagramName);
|
||||
useKeyPressEvent('Enter', editDiagramName);
|
||||
useKeyPressEvent('Escape', abortEdit);
|
||||
|
||||
const enterEditMode = useCallback(
|
||||
(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
event.stopPropagation();
|
||||
setEditedDiagramName(diagramName);
|
||||
setEditMode(true);
|
||||
},
|
||||
[diagramName]
|
||||
);
|
||||
const enterEditMode = (
|
||||
event: React.MouseEvent<HTMLHeadingElement, MouseEvent>
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
setEditMode(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group">
|
||||
@@ -104,16 +81,10 @@ export const DiagramName: React.FC<DiagramNameProps> = () => {
|
||||
setEditedDiagramName(e.target.value)
|
||||
}
|
||||
className="ml-1 h-7 focus-visible:ring-0"
|
||||
style={{
|
||||
width: `${Math.max(
|
||||
editedDiagramName.length * 8 + 20,
|
||||
100
|
||||
)}px`,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="ml-1 flex size-7 p-2 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300"
|
||||
className="flex size-7 p-2 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300"
|
||||
onClick={editDiagramName}
|
||||
>
|
||||
<Check />
|
||||
@@ -139,13 +110,6 @@ export const DiagramName: React.FC<DiagramNameProps> = () => {
|
||||
{t('tool_tips.double_click_to_edit')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="ml-1 size-5 p-0 opacity-0 transition-opacity hover:bg-primary-foreground group-hover:opacity-100"
|
||||
onClick={enterEditMode}
|
||||
>
|
||||
<Pencil className="size-3 text-slate-500 dark:text-slate-400" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user