mirror of
https://github.com/chartdb/chartdb.git
synced 2025-11-02 13:03:17 +00:00
feat(custom-types): add enums and composite types for Postgres (#714)
* feat(postgres): add custom datatypes to import script * import only enums & composite types * init commit to support custom types in postgres * add support in matching custom fields on import + be able to choose it as type for feild * update select box + type names * all but ui * update ui * fix build * fix build * fix --------- Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
This commit is contained in:
@@ -26,6 +26,7 @@ export interface SelectBoxOption {
|
||||
description?: string;
|
||||
regex?: string;
|
||||
extractRegex?: RegExp;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export interface SelectBoxProps {
|
||||
@@ -51,6 +52,7 @@ export interface SelectBoxProps {
|
||||
disabled?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
popoverClassName?: string;
|
||||
}
|
||||
|
||||
export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
@@ -75,6 +77,7 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
disabled,
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
popoverClassName,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -175,6 +178,101 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
[isOpen, onOpenChange]
|
||||
);
|
||||
|
||||
const groups = React.useMemo(
|
||||
() =>
|
||||
options.reduce(
|
||||
(acc, option) => {
|
||||
if (option.group) {
|
||||
if (!acc[option.group]) {
|
||||
acc[option.group] = [];
|
||||
}
|
||||
acc[option.group].push(option);
|
||||
} else {
|
||||
if (!acc['default']) {
|
||||
acc['default'] = [];
|
||||
}
|
||||
acc['default'].push(option);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, SelectBoxOption[]>
|
||||
),
|
||||
[options]
|
||||
);
|
||||
|
||||
const hasGroups = React.useMemo(
|
||||
() =>
|
||||
Object.keys(groups).filter((group) => group !== 'default')
|
||||
.length > 0,
|
||||
[groups]
|
||||
);
|
||||
|
||||
const renderOption = React.useCallback(
|
||||
(option: SelectBoxOption) => {
|
||||
const isSelected =
|
||||
Array.isArray(value) && value.includes(option.value);
|
||||
|
||||
const isRegexMatch =
|
||||
option.regex && new RegExp(option.regex)?.test(searchTerm);
|
||||
|
||||
const matches = option.extractRegex
|
||||
? searchTerm.match(option.extractRegex)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className="flex items-center"
|
||||
key={option.value}
|
||||
keywords={option.regex ? [option.regex] : undefined}
|
||||
onSelect={() =>
|
||||
handleSelect(
|
||||
option.value,
|
||||
matches?.map((match) => match.toString())
|
||||
)
|
||||
}
|
||||
>
|
||||
{multiple && (
|
||||
<div
|
||||
className={cn(
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
)}
|
||||
>
|
||||
<CheckIcon />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 items-center truncate">
|
||||
<span>
|
||||
{isRegexMatch ? searchTerm : option.label}
|
||||
{!isRegexMatch && optionSuffix
|
||||
? optionSuffix(option)
|
||||
: ''}
|
||||
</span>
|
||||
{option.description && (
|
||||
<span className="ml-1 w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{((!multiple && option.value === value) ||
|
||||
isRegexMatch) && (
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'ml-auto',
|
||||
option.value === value
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
},
|
||||
[value, multiple, searchTerm, handleSelect, optionSuffix]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onOpenChange} modal={true}>
|
||||
<PopoverTrigger asChild tabIndex={0} onKeyDown={handleKeyDown}>
|
||||
@@ -245,7 +343,10 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-fit min-w-[var(--radix-popover-trigger-width)] p-0"
|
||||
className={cn(
|
||||
'w-fit min-w-[var(--radix-popover-trigger-width)] p-0',
|
||||
popoverClassName
|
||||
)}
|
||||
align="center"
|
||||
>
|
||||
<Command
|
||||
@@ -319,91 +420,23 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
<div className="max-h-64 w-full">
|
||||
<CommandGroup>
|
||||
<CommandList className="max-h-fit w-full">
|
||||
{options.map((option) => {
|
||||
const isSelected =
|
||||
Array.isArray(value) &&
|
||||
value.includes(option.value);
|
||||
|
||||
const isRegexMatch =
|
||||
option.regex &&
|
||||
new RegExp(option.regex)?.test(
|
||||
searchTerm
|
||||
);
|
||||
|
||||
const matches = option.extractRegex
|
||||
? searchTerm.match(
|
||||
option.extractRegex
|
||||
{hasGroups
|
||||
? Object.entries(groups).map(
|
||||
([
|
||||
groupName,
|
||||
groupOptions,
|
||||
]) => (
|
||||
<CommandGroup
|
||||
key={groupName}
|
||||
heading={groupName}
|
||||
>
|
||||
{groupOptions.map(
|
||||
renderOption
|
||||
)}
|
||||
</CommandGroup>
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className="flex items-center"
|
||||
key={option.value}
|
||||
keywords={
|
||||
option.regex
|
||||
? [option.regex]
|
||||
: undefined
|
||||
}
|
||||
onSelect={() =>
|
||||
handleSelect(
|
||||
option.value,
|
||||
matches?.map(
|
||||
(match) =>
|
||||
match.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
{multiple && (
|
||||
<div
|
||||
className={cn(
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
)}
|
||||
>
|
||||
<CheckIcon />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center truncate">
|
||||
<span>
|
||||
{isRegexMatch
|
||||
? searchTerm
|
||||
: option.label}
|
||||
{!isRegexMatch &&
|
||||
optionSuffix
|
||||
? optionSuffix(
|
||||
option
|
||||
)
|
||||
: ''}
|
||||
</span>
|
||||
{option.description && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
{
|
||||
option.description
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{((!multiple &&
|
||||
option.value ===
|
||||
value) ||
|
||||
isRegexMatch) && (
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'ml-auto',
|
||||
option.value ===
|
||||
value
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
)
|
||||
: options.map(renderOption)}
|
||||
</CommandList>
|
||||
</CommandGroup>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { DBSchema } from '@/lib/domain/db-schema';
|
||||
import type { DBDependency } from '@/lib/domain/db-dependency';
|
||||
import { EventEmitter } from 'ahooks/lib/useEventEmitter';
|
||||
import type { Area } from '@/lib/domain/area';
|
||||
import type { DBCustomType } from '@/lib/domain/db-custom-type';
|
||||
|
||||
export type ChartDBEventType =
|
||||
| 'add_tables'
|
||||
@@ -72,6 +73,7 @@ export interface ChartDBContext {
|
||||
relationships: DBRelationship[];
|
||||
dependencies: DBDependency[];
|
||||
areas: Area[];
|
||||
customTypes: DBCustomType[];
|
||||
currentDiagram: Diagram;
|
||||
events: EventEmitter<ChartDBEvent>;
|
||||
readonly?: boolean;
|
||||
@@ -248,6 +250,33 @@ export interface ChartDBContext {
|
||||
area: Partial<Area>,
|
||||
options?: { updateHistory: boolean }
|
||||
) => Promise<void>;
|
||||
|
||||
// Custom type operations
|
||||
createCustomType: (
|
||||
attributes?: Partial<Omit<DBCustomType, 'id'>>
|
||||
) => Promise<DBCustomType>;
|
||||
addCustomType: (
|
||||
customType: DBCustomType,
|
||||
options?: { updateHistory: boolean }
|
||||
) => Promise<void>;
|
||||
addCustomTypes: (
|
||||
customTypes: DBCustomType[],
|
||||
options?: { updateHistory: boolean }
|
||||
) => Promise<void>;
|
||||
getCustomType: (id: string) => DBCustomType | null;
|
||||
removeCustomType: (
|
||||
id: string,
|
||||
options?: { updateHistory: boolean }
|
||||
) => Promise<void>;
|
||||
removeCustomTypes: (
|
||||
ids: string[],
|
||||
options?: { updateHistory: boolean }
|
||||
) => Promise<void>;
|
||||
updateCustomType: (
|
||||
id: string,
|
||||
customType: Partial<DBCustomType>,
|
||||
options?: { updateHistory: boolean }
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export const chartDBContext = createContext<ChartDBContext>({
|
||||
@@ -258,6 +287,7 @@ export const chartDBContext = createContext<ChartDBContext>({
|
||||
relationships: [],
|
||||
dependencies: [],
|
||||
areas: [],
|
||||
customTypes: [],
|
||||
schemas: [],
|
||||
filteredSchemas: [],
|
||||
filterSchemas: emptyFn,
|
||||
@@ -333,4 +363,13 @@ export const chartDBContext = createContext<ChartDBContext>({
|
||||
removeArea: emptyFn,
|
||||
removeAreas: emptyFn,
|
||||
updateArea: emptyFn,
|
||||
|
||||
// Custom type operations
|
||||
createCustomType: emptyFn,
|
||||
addCustomType: emptyFn,
|
||||
addCustomTypes: emptyFn,
|
||||
getCustomType: emptyFn,
|
||||
removeCustomType: emptyFn,
|
||||
removeCustomTypes: emptyFn,
|
||||
updateCustomType: emptyFn,
|
||||
});
|
||||
|
||||
@@ -25,6 +25,10 @@ import type { Area } from '@/lib/domain/area';
|
||||
import { storageInitialValue } from '../storage-context/storage-context';
|
||||
import { useDiff } from '../diff-context/use-diff';
|
||||
import type { DiffCalculatedEvent } from '../diff-context/diff-context';
|
||||
import {
|
||||
DBCustomTypeKind,
|
||||
type DBCustomType,
|
||||
} from '@/lib/domain/db-custom-type';
|
||||
|
||||
export interface ChartDBProviderProps {
|
||||
diagram?: Diagram;
|
||||
@@ -58,6 +62,9 @@ export const ChartDBProvider: React.FC<
|
||||
diagram?.dependencies ?? []
|
||||
);
|
||||
const [areas, setAreas] = useState<Area[]>(diagram?.areas ?? []);
|
||||
const [customTypes, setCustomTypes] = useState<DBCustomType[]>(
|
||||
diagram?.customTypes ?? []
|
||||
);
|
||||
const { events: diffEvents } = useDiff();
|
||||
|
||||
const diffCalculatedHandler = useCallback((event: DiffCalculatedEvent) => {
|
||||
@@ -155,6 +162,7 @@ export const ChartDBProvider: React.FC<
|
||||
relationships,
|
||||
dependencies,
|
||||
areas,
|
||||
customTypes,
|
||||
}),
|
||||
[
|
||||
diagramId,
|
||||
@@ -165,6 +173,7 @@ export const ChartDBProvider: React.FC<
|
||||
relationships,
|
||||
dependencies,
|
||||
areas,
|
||||
customTypes,
|
||||
diagramCreatedAt,
|
||||
diagramUpdatedAt,
|
||||
]
|
||||
@@ -177,6 +186,7 @@ export const ChartDBProvider: React.FC<
|
||||
setRelationships([]);
|
||||
setDependencies([]);
|
||||
setAreas([]);
|
||||
setCustomTypes([]);
|
||||
setDiagramUpdatedAt(updatedAt);
|
||||
|
||||
resetRedoStack();
|
||||
@@ -188,6 +198,7 @@ export const ChartDBProvider: React.FC<
|
||||
db.deleteDiagramRelationships(diagramId),
|
||||
db.deleteDiagramDependencies(diagramId),
|
||||
db.deleteDiagramAreas(diagramId),
|
||||
db.deleteDiagramCustomTypes(diagramId),
|
||||
]);
|
||||
}, [db, diagramId, resetRedoStack, resetUndoStack]);
|
||||
|
||||
@@ -201,6 +212,7 @@ export const ChartDBProvider: React.FC<
|
||||
setRelationships([]);
|
||||
setDependencies([]);
|
||||
setAreas([]);
|
||||
setCustomTypes([]);
|
||||
resetRedoStack();
|
||||
resetUndoStack();
|
||||
|
||||
@@ -210,6 +222,7 @@ export const ChartDBProvider: React.FC<
|
||||
db.deleteDiagram(diagramId),
|
||||
db.deleteDiagramDependencies(diagramId),
|
||||
db.deleteDiagramAreas(diagramId),
|
||||
db.deleteDiagramCustomTypes(diagramId),
|
||||
]);
|
||||
}, [db, diagramId, resetRedoStack, resetUndoStack]);
|
||||
|
||||
@@ -1506,6 +1519,7 @@ export const ChartDBProvider: React.FC<
|
||||
setRelationships(diagram?.relationships ?? []);
|
||||
setDependencies(diagram?.dependencies ?? []);
|
||||
setAreas(diagram?.areas ?? []);
|
||||
setCustomTypes(diagram?.customTypes ?? []);
|
||||
setDiagramCreatedAt(diagram.createdAt);
|
||||
setDiagramUpdatedAt(diagram.updatedAt);
|
||||
|
||||
@@ -1520,6 +1534,7 @@ export const ChartDBProvider: React.FC<
|
||||
setRelationships,
|
||||
setDependencies,
|
||||
setAreas,
|
||||
setCustomTypes,
|
||||
setDiagramCreatedAt,
|
||||
setDiagramUpdatedAt,
|
||||
events,
|
||||
@@ -1533,6 +1548,7 @@ export const ChartDBProvider: React.FC<
|
||||
includeTables: true,
|
||||
includeDependencies: true,
|
||||
includeAreas: true,
|
||||
includeCustomTypes: true,
|
||||
});
|
||||
|
||||
if (diagram) {
|
||||
@@ -1544,6 +1560,150 @@ export const ChartDBProvider: React.FC<
|
||||
[db, loadDiagramFromData]
|
||||
);
|
||||
|
||||
// Custom type operations
|
||||
const getCustomType: ChartDBContext['getCustomType'] = useCallback(
|
||||
(id: string) => customTypes.find((type) => type.id === id) ?? null,
|
||||
[customTypes]
|
||||
);
|
||||
|
||||
const addCustomTypes: ChartDBContext['addCustomTypes'] = useCallback(
|
||||
async (
|
||||
customTypes: DBCustomType[],
|
||||
options = { updateHistory: true }
|
||||
) => {
|
||||
setCustomTypes((currentTypes) => [...currentTypes, ...customTypes]);
|
||||
const updatedAt = new Date();
|
||||
setDiagramUpdatedAt(updatedAt);
|
||||
|
||||
await Promise.all([
|
||||
db.updateDiagram({ id: diagramId, attributes: { updatedAt } }),
|
||||
...customTypes.map((customType) =>
|
||||
db.addCustomType({ diagramId, customType })
|
||||
),
|
||||
]);
|
||||
|
||||
if (options.updateHistory) {
|
||||
addUndoAction({
|
||||
action: 'addCustomTypes',
|
||||
redoData: { customTypes },
|
||||
undoData: { customTypeIds: customTypes.map((t) => t.id) },
|
||||
});
|
||||
resetRedoStack();
|
||||
}
|
||||
},
|
||||
[db, diagramId, setCustomTypes, addUndoAction, resetRedoStack]
|
||||
);
|
||||
|
||||
const addCustomType: ChartDBContext['addCustomType'] = useCallback(
|
||||
async (customType: DBCustomType, options = { updateHistory: true }) => {
|
||||
return addCustomTypes([customType], options);
|
||||
},
|
||||
[addCustomTypes]
|
||||
);
|
||||
|
||||
const createCustomType: ChartDBContext['createCustomType'] = useCallback(
|
||||
async (attributes) => {
|
||||
const customType: DBCustomType = {
|
||||
id: generateId(),
|
||||
name: `type_${customTypes.length + 1}`,
|
||||
kind: DBCustomTypeKind.enum,
|
||||
values: [],
|
||||
fields: [],
|
||||
...attributes,
|
||||
};
|
||||
|
||||
await addCustomType(customType);
|
||||
return customType;
|
||||
},
|
||||
[addCustomType, customTypes]
|
||||
);
|
||||
|
||||
const removeCustomTypes: ChartDBContext['removeCustomTypes'] = useCallback(
|
||||
async (ids, options = { updateHistory: true }) => {
|
||||
const typesToRemove = ids
|
||||
.map((id) => getCustomType(id))
|
||||
.filter(Boolean) as DBCustomType[];
|
||||
|
||||
setCustomTypes((types) =>
|
||||
types.filter((type) => !ids.includes(type.id))
|
||||
);
|
||||
|
||||
const updatedAt = new Date();
|
||||
setDiagramUpdatedAt(updatedAt);
|
||||
|
||||
await Promise.all([
|
||||
db.updateDiagram({ id: diagramId, attributes: { updatedAt } }),
|
||||
...ids.map((id) => db.deleteCustomType({ diagramId, id })),
|
||||
]);
|
||||
|
||||
if (typesToRemove.length > 0 && options.updateHistory) {
|
||||
addUndoAction({
|
||||
action: 'removeCustomTypes',
|
||||
redoData: {
|
||||
customTypeIds: ids,
|
||||
},
|
||||
undoData: {
|
||||
customTypes: typesToRemove,
|
||||
},
|
||||
});
|
||||
resetRedoStack();
|
||||
}
|
||||
},
|
||||
[
|
||||
db,
|
||||
diagramId,
|
||||
setCustomTypes,
|
||||
addUndoAction,
|
||||
resetRedoStack,
|
||||
getCustomType,
|
||||
]
|
||||
);
|
||||
|
||||
const removeCustomType: ChartDBContext['removeCustomType'] = useCallback(
|
||||
async (id: string, options = { updateHistory: true }) => {
|
||||
return removeCustomTypes([id], options);
|
||||
},
|
||||
[removeCustomTypes]
|
||||
);
|
||||
|
||||
const updateCustomType: ChartDBContext['updateCustomType'] = useCallback(
|
||||
async (
|
||||
id: string,
|
||||
customType: Partial<DBCustomType>,
|
||||
options = { updateHistory: true }
|
||||
) => {
|
||||
const prevCustomType = getCustomType(id);
|
||||
setCustomTypes((types) =>
|
||||
types.map((t) => (t.id === id ? { ...t, ...customType } : t))
|
||||
);
|
||||
|
||||
const updatedAt = new Date();
|
||||
setDiagramUpdatedAt(updatedAt);
|
||||
|
||||
await Promise.all([
|
||||
db.updateDiagram({ id: diagramId, attributes: { updatedAt } }),
|
||||
db.updateCustomType({ id, attributes: customType }),
|
||||
]);
|
||||
|
||||
if (!!prevCustomType && options.updateHistory) {
|
||||
addUndoAction({
|
||||
action: 'updateCustomType',
|
||||
redoData: { customTypeId: id, customType },
|
||||
undoData: { customTypeId: id, customType: prevCustomType },
|
||||
});
|
||||
resetRedoStack();
|
||||
}
|
||||
},
|
||||
[
|
||||
db,
|
||||
setCustomTypes,
|
||||
addUndoAction,
|
||||
resetRedoStack,
|
||||
getCustomType,
|
||||
diagramId,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<chartDBContext.Provider
|
||||
value={{
|
||||
@@ -1608,6 +1768,14 @@ export const ChartDBProvider: React.FC<
|
||||
removeArea,
|
||||
removeAreas,
|
||||
updateArea,
|
||||
customTypes,
|
||||
createCustomType,
|
||||
addCustomType,
|
||||
addCustomTypes,
|
||||
getCustomType,
|
||||
removeCustomType,
|
||||
removeCustomTypes,
|
||||
updateCustomType,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -36,6 +36,9 @@ export const HistoryProvider: React.FC<React.PropsWithChildren> = ({
|
||||
addAreas,
|
||||
removeAreas,
|
||||
updateArea,
|
||||
addCustomTypes,
|
||||
removeCustomTypes,
|
||||
updateCustomType,
|
||||
} = useChartDB();
|
||||
|
||||
const redoActionHandlers = useMemo(
|
||||
@@ -119,6 +122,19 @@ export const HistoryProvider: React.FC<React.PropsWithChildren> = ({
|
||||
updateArea: ({ redoData: { areaId, area } }) => {
|
||||
return updateArea(areaId, area, { updateHistory: false });
|
||||
},
|
||||
addCustomTypes: ({ redoData: { customTypes } }) => {
|
||||
return addCustomTypes(customTypes, { updateHistory: false });
|
||||
},
|
||||
removeCustomTypes: ({ redoData: { customTypeIds } }) => {
|
||||
return removeCustomTypes(customTypeIds, {
|
||||
updateHistory: false,
|
||||
});
|
||||
},
|
||||
updateCustomType: ({ redoData: { customTypeId, customType } }) => {
|
||||
return updateCustomType(customTypeId, customType, {
|
||||
updateHistory: false,
|
||||
});
|
||||
},
|
||||
}),
|
||||
[
|
||||
addTables,
|
||||
@@ -141,6 +157,9 @@ export const HistoryProvider: React.FC<React.PropsWithChildren> = ({
|
||||
addAreas,
|
||||
removeAreas,
|
||||
updateArea,
|
||||
addCustomTypes,
|
||||
removeCustomTypes,
|
||||
updateCustomType,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -239,6 +258,19 @@ export const HistoryProvider: React.FC<React.PropsWithChildren> = ({
|
||||
updateArea: ({ undoData: { areaId, area } }) => {
|
||||
return updateArea(areaId, area, { updateHistory: false });
|
||||
},
|
||||
addCustomTypes: ({ undoData: { customTypeIds } }) => {
|
||||
return removeCustomTypes(customTypeIds, {
|
||||
updateHistory: false,
|
||||
});
|
||||
},
|
||||
removeCustomTypes: ({ undoData: { customTypes } }) => {
|
||||
return addCustomTypes(customTypes, { updateHistory: false });
|
||||
},
|
||||
updateCustomType: ({ undoData: { customTypeId, customType } }) => {
|
||||
return updateCustomType(customTypeId, customType, {
|
||||
updateHistory: false,
|
||||
});
|
||||
},
|
||||
}),
|
||||
[
|
||||
addTables,
|
||||
@@ -261,6 +293,9 @@ export const HistoryProvider: React.FC<React.PropsWithChildren> = ({
|
||||
addAreas,
|
||||
removeAreas,
|
||||
updateArea,
|
||||
addCustomTypes,
|
||||
removeCustomTypes,
|
||||
updateCustomType,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { DBIndex } from '@/lib/domain/db-index';
|
||||
import type { DBRelationship } from '@/lib/domain/db-relationship';
|
||||
import type { DBDependency } from '@/lib/domain/db-dependency';
|
||||
import type { Area } from '@/lib/domain/area';
|
||||
import type { DBCustomType } from '@/lib/domain/db-custom-type';
|
||||
|
||||
type Action = keyof ChartDBContext;
|
||||
|
||||
@@ -142,6 +143,24 @@ type RedoUndoActionRemoveAreas = RedoUndoActionBase<
|
||||
{ areas: Area[] }
|
||||
>;
|
||||
|
||||
type RedoUndoActionAddCustomTypes = RedoUndoActionBase<
|
||||
'addCustomTypes',
|
||||
{ customTypes: DBCustomType[] },
|
||||
{ customTypeIds: string[] }
|
||||
>;
|
||||
|
||||
type RedoUndoActionUpdateCustomType = RedoUndoActionBase<
|
||||
'updateCustomType',
|
||||
{ customTypeId: string; customType: Partial<DBCustomType> },
|
||||
{ customTypeId: string; customType: Partial<DBCustomType> }
|
||||
>;
|
||||
|
||||
type RedoUndoActionRemoveCustomTypes = RedoUndoActionBase<
|
||||
'removeCustomTypes',
|
||||
{ customTypeIds: string[] },
|
||||
{ customTypes: DBCustomType[] }
|
||||
>;
|
||||
|
||||
export type RedoUndoAction =
|
||||
| RedoUndoActionAddTables
|
||||
| RedoUndoActionRemoveTables
|
||||
@@ -162,7 +181,10 @@ export type RedoUndoAction =
|
||||
| RedoUndoActionRemoveDependencies
|
||||
| RedoUndoActionAddAreas
|
||||
| RedoUndoActionUpdateArea
|
||||
| RedoUndoActionRemoveAreas;
|
||||
| RedoUndoActionRemoveAreas
|
||||
| RedoUndoActionAddCustomTypes
|
||||
| RedoUndoActionUpdateCustomType
|
||||
| RedoUndoActionRemoveCustomTypes;
|
||||
|
||||
export type RedoActionData<T extends Action> = Extract<
|
||||
RedoUndoAction,
|
||||
|
||||
@@ -5,7 +5,8 @@ export type SidebarSection =
|
||||
| 'tables'
|
||||
| 'relationships'
|
||||
| 'dependencies'
|
||||
| 'areas';
|
||||
| 'areas'
|
||||
| 'customTypes';
|
||||
|
||||
export interface LayoutContext {
|
||||
openedTableInSidebar: string | undefined;
|
||||
@@ -24,6 +25,10 @@ export interface LayoutContext {
|
||||
openAreaFromSidebar: (areaId: string) => void;
|
||||
closeAllAreasInSidebar: () => void;
|
||||
|
||||
openedCustomTypeInSidebar: string | undefined;
|
||||
openCustomTypeFromSidebar: (customTypeId: string) => void;
|
||||
closeAllCustomTypesInSidebar: () => void;
|
||||
|
||||
selectedSidebarSection: SidebarSection;
|
||||
selectSidebarSection: (section: SidebarSection) => void;
|
||||
|
||||
@@ -53,6 +58,10 @@ export const layoutContext = createContext<LayoutContext>({
|
||||
openAreaFromSidebar: emptyFn,
|
||||
closeAllAreasInSidebar: emptyFn,
|
||||
|
||||
openedCustomTypeInSidebar: undefined,
|
||||
openCustomTypeFromSidebar: emptyFn,
|
||||
closeAllCustomTypesInSidebar: emptyFn,
|
||||
|
||||
selectSidebarSection: emptyFn,
|
||||
openTableFromSidebar: emptyFn,
|
||||
closeAllTablesInSidebar: emptyFn,
|
||||
|
||||
@@ -17,6 +17,8 @@ export const LayoutProvider: React.FC<React.PropsWithChildren> = ({
|
||||
const [openedAreaInSidebar, setOpenedAreaInSidebar] = React.useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [openedCustomTypeInSidebar, setOpenedCustomTypeInSidebar] =
|
||||
React.useState<string | undefined>();
|
||||
const [selectedSidebarSection, setSelectedSidebarSection] =
|
||||
React.useState<SidebarSection>('tables');
|
||||
const [isSidePanelShowed, setIsSidePanelShowed] =
|
||||
@@ -36,6 +38,9 @@ export const LayoutProvider: React.FC<React.PropsWithChildren> = ({
|
||||
const closeAllAreasInSidebar: LayoutContext['closeAllAreasInSidebar'] =
|
||||
() => setOpenedAreaInSidebar('');
|
||||
|
||||
const closeAllCustomTypesInSidebar: LayoutContext['closeAllCustomTypesInSidebar'] =
|
||||
() => setOpenedCustomTypeInSidebar('');
|
||||
|
||||
const hideSidePanel: LayoutContext['hideSidePanel'] = () =>
|
||||
setIsSidePanelShowed(false);
|
||||
|
||||
@@ -76,6 +81,13 @@ export const LayoutProvider: React.FC<React.PropsWithChildren> = ({
|
||||
setOpenedAreaInSidebar(areaId);
|
||||
};
|
||||
|
||||
const openCustomTypeFromSidebar: LayoutContext['openCustomTypeFromSidebar'] =
|
||||
(customTypeId) => {
|
||||
showSidePanel();
|
||||
setSelectedSidebarSection('customTypes');
|
||||
setOpenedTableInSidebar(customTypeId);
|
||||
};
|
||||
|
||||
const openSelectSchema: LayoutContext['openSelectSchema'] = () =>
|
||||
setIsSelectSchemaOpen(true);
|
||||
|
||||
@@ -105,6 +117,9 @@ export const LayoutProvider: React.FC<React.PropsWithChildren> = ({
|
||||
openedAreaInSidebar,
|
||||
openAreaFromSidebar,
|
||||
closeAllAreasInSidebar,
|
||||
openedCustomTypeInSidebar,
|
||||
openCustomTypeFromSidebar,
|
||||
closeAllCustomTypesInSidebar,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { DBTable } from '@/lib/domain/db-table';
|
||||
import type { ChartDBConfig } from '@/lib/domain/config';
|
||||
import type { DBDependency } from '@/lib/domain/db-dependency';
|
||||
import type { Area } from '@/lib/domain/area';
|
||||
import type { DBCustomType } from '@/lib/domain/db-custom-type';
|
||||
|
||||
export interface StorageContext {
|
||||
// Config operations
|
||||
@@ -27,6 +28,7 @@ export interface StorageContext {
|
||||
includeRelationships?: boolean;
|
||||
includeDependencies?: boolean;
|
||||
includeAreas?: boolean;
|
||||
includeCustomTypes?: boolean;
|
||||
}
|
||||
) => Promise<Diagram | undefined>;
|
||||
updateDiagram: (params: {
|
||||
@@ -103,6 +105,26 @@ export interface StorageContext {
|
||||
deleteArea: (params: { diagramId: string; id: string }) => Promise<void>;
|
||||
listAreas: (diagramId: string) => Promise<Area[]>;
|
||||
deleteDiagramAreas: (diagramId: string) => Promise<void>;
|
||||
|
||||
// Custom type operations
|
||||
addCustomType: (params: {
|
||||
diagramId: string;
|
||||
customType: DBCustomType;
|
||||
}) => Promise<void>;
|
||||
getCustomType: (params: {
|
||||
diagramId: string;
|
||||
id: string;
|
||||
}) => Promise<DBCustomType | undefined>;
|
||||
updateCustomType: (params: {
|
||||
id: string;
|
||||
attributes: Partial<DBCustomType>;
|
||||
}) => Promise<void>;
|
||||
deleteCustomType: (params: {
|
||||
diagramId: string;
|
||||
id: string;
|
||||
}) => Promise<void>;
|
||||
listCustomTypes: (diagramId: string) => Promise<DBCustomType[]>;
|
||||
deleteDiagramCustomTypes: (diagramId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const storageInitialValue: StorageContext = {
|
||||
@@ -143,6 +165,14 @@ export const storageInitialValue: StorageContext = {
|
||||
deleteArea: emptyFn,
|
||||
listAreas: emptyFn,
|
||||
deleteDiagramAreas: emptyFn,
|
||||
|
||||
// Custom type operations
|
||||
addCustomType: emptyFn,
|
||||
getCustomType: emptyFn,
|
||||
updateCustomType: emptyFn,
|
||||
deleteCustomType: emptyFn,
|
||||
listCustomTypes: emptyFn,
|
||||
deleteDiagramCustomTypes: emptyFn,
|
||||
};
|
||||
|
||||
export const storageContext =
|
||||
|
||||
@@ -9,6 +9,7 @@ import { determineCardinalities } from '@/lib/domain/db-relationship';
|
||||
import type { ChartDBConfig } from '@/lib/domain/config';
|
||||
import type { DBDependency } from '@/lib/domain/db-dependency';
|
||||
import type { Area } from '@/lib/domain/area';
|
||||
import type { DBCustomType } from '@/lib/domain/db-custom-type';
|
||||
|
||||
export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
children,
|
||||
@@ -34,6 +35,10 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
Area & { diagramId: string },
|
||||
'id' // primary key "id" (for the typings only)
|
||||
>;
|
||||
db_custom_types: EntityTable<
|
||||
DBCustomType & { diagramId: string },
|
||||
'id' // primary key "id" (for the typings only)
|
||||
>;
|
||||
config: EntityTable<
|
||||
ChartDBConfig & { id: number },
|
||||
'id' // primary key "id" (for the typings only)
|
||||
@@ -166,6 +171,20 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
config: '++id, defaultDiagramId',
|
||||
});
|
||||
|
||||
db.version(11).stores({
|
||||
diagrams:
|
||||
'++id, name, databaseType, databaseEdition, createdAt, updatedAt',
|
||||
db_tables:
|
||||
'++id, diagramId, name, schema, x, y, fields, indexes, color, createdAt, width, comment, isView, isMaterializedView, order',
|
||||
db_relationships:
|
||||
'++id, diagramId, name, sourceSchema, sourceTableId, targetSchema, targetTableId, sourceFieldId, targetFieldId, type, createdAt',
|
||||
db_dependencies:
|
||||
'++id, diagramId, schema, tableId, dependentSchema, dependentTableId, createdAt',
|
||||
areas: '++id, diagramId, name, x, y, width, height, color',
|
||||
db_custom_types: '++id, diagramId, schema, type, kind, values, fields',
|
||||
config: '++id, defaultDiagramId',
|
||||
});
|
||||
|
||||
db.on('ready', async () => {
|
||||
const config = await getConfig();
|
||||
|
||||
@@ -232,6 +251,13 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
...areas.map((area) => addArea({ diagramId: diagram.id, area }))
|
||||
);
|
||||
|
||||
const customTypes = diagram.customTypes ?? [];
|
||||
promises.push(
|
||||
...customTypes.map((customType) =>
|
||||
addCustomType({ diagramId: diagram.id, customType })
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
};
|
||||
|
||||
@@ -296,11 +322,13 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
includeRelationships?: boolean;
|
||||
includeDependencies?: boolean;
|
||||
includeAreas?: boolean;
|
||||
includeCustomTypes?: boolean;
|
||||
} = {
|
||||
includeRelationships: false,
|
||||
includeTables: false,
|
||||
includeDependencies: false,
|
||||
includeAreas: false,
|
||||
includeCustomTypes: false,
|
||||
}
|
||||
): Promise<Diagram | undefined> => {
|
||||
const diagram = await db.diagrams.get(id);
|
||||
@@ -325,6 +353,10 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
diagram.areas = await listAreas(id);
|
||||
}
|
||||
|
||||
if (options.includeCustomTypes) {
|
||||
diagram.customTypes = await listCustomTypes(id);
|
||||
}
|
||||
|
||||
return diagram;
|
||||
};
|
||||
|
||||
@@ -364,6 +396,7 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
db.db_relationships.where('diagramId').equals(id).delete(),
|
||||
db.db_dependencies.where('diagramId').equals(id).delete(),
|
||||
db.areas.where('diagramId').equals(id).delete(),
|
||||
db.db_custom_types.where('diagramId').equals(id).delete(),
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -580,6 +613,71 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
await db.areas.where('diagramId').equals(diagramId).delete();
|
||||
};
|
||||
|
||||
// Custom type operations
|
||||
const addCustomType: StorageContext['addCustomType'] = async ({
|
||||
diagramId,
|
||||
customType,
|
||||
}: {
|
||||
diagramId: string;
|
||||
customType: DBCustomType;
|
||||
}) => {
|
||||
await db.db_custom_types.add({
|
||||
...customType,
|
||||
diagramId,
|
||||
});
|
||||
};
|
||||
|
||||
const getCustomType: StorageContext['getCustomType'] = async ({
|
||||
diagramId,
|
||||
id,
|
||||
}: {
|
||||
diagramId: string;
|
||||
id: string;
|
||||
}): Promise<DBCustomType | undefined> => {
|
||||
return await db.db_custom_types.get({ id, diagramId });
|
||||
};
|
||||
|
||||
const updateCustomType: StorageContext['updateCustomType'] = async ({
|
||||
id,
|
||||
attributes,
|
||||
}: {
|
||||
id: string;
|
||||
attributes: Partial<DBCustomType>;
|
||||
}) => {
|
||||
await db.db_custom_types.update(id, attributes);
|
||||
};
|
||||
|
||||
const deleteCustomType: StorageContext['deleteCustomType'] = async ({
|
||||
diagramId,
|
||||
id,
|
||||
}: {
|
||||
id: string;
|
||||
diagramId: string;
|
||||
}) => {
|
||||
await db.db_custom_types.where({ id, diagramId }).delete();
|
||||
};
|
||||
|
||||
const listCustomTypes: StorageContext['listCustomTypes'] = async (
|
||||
diagramId: string
|
||||
): Promise<DBCustomType[]> => {
|
||||
return (
|
||||
await db.db_custom_types
|
||||
.where('diagramId')
|
||||
.equals(diagramId)
|
||||
.toArray()
|
||||
).sort((a, b) => {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteDiagramCustomTypes: StorageContext['deleteDiagramCustomTypes'] =
|
||||
async (diagramId: string) => {
|
||||
await db.db_custom_types
|
||||
.where('diagramId')
|
||||
.equals(diagramId)
|
||||
.delete();
|
||||
};
|
||||
|
||||
return (
|
||||
<storageContext.Provider
|
||||
value={{
|
||||
@@ -615,6 +713,12 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
deleteArea,
|
||||
listAreas,
|
||||
deleteDiagramAreas,
|
||||
addCustomType,
|
||||
getCustomType,
|
||||
updateCustomType,
|
||||
deleteCustomType,
|
||||
listCustomTypes,
|
||||
deleteDiagramCustomTypes,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -231,6 +231,33 @@ export const ar: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -232,6 +232,32 @@ export const bn: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -234,6 +234,32 @@ export const de: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -226,6 +226,32 @@ export const en = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -222,6 +222,32 @@ export const es: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -220,6 +220,32 @@ export const fr: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -233,6 +233,32 @@ export const gu: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -233,6 +233,32 @@ export const hi: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -231,6 +231,32 @@ export const id_ID: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -237,6 +237,32 @@ export const ja: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -231,6 +231,32 @@ export const ko_KR: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -236,6 +236,32 @@ export const mr: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -233,6 +233,32 @@ export const ne: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -232,6 +232,32 @@ export const pt_BR: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -229,6 +229,32 @@ export const ru: LanguageTranslation = {
|
||||
description: 'Создайте область, чтобы начать',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -233,6 +233,32 @@ export const te: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -232,6 +232,32 @@ export const tr: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
toolbar: {
|
||||
zoom_in: 'Yakınlaştır',
|
||||
|
||||
@@ -230,6 +230,32 @@ export const uk: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -231,6 +231,32 @@ export const vi: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -228,6 +228,32 @@ export const zh_CN: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -228,6 +228,32 @@ export const zh_TW: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
clear: 'Clear Filter',
|
||||
no_results: 'No custom types found matching your filter.',
|
||||
empty_state: {
|
||||
title: 'No custom types',
|
||||
description:
|
||||
'Custom types will appear here when they are available in your database',
|
||||
},
|
||||
custom_type: {
|
||||
kind: 'Kind',
|
||||
enum_values: 'Enum Values',
|
||||
composite_fields: 'Fields',
|
||||
no_fields: 'No fields defined',
|
||||
field_name_placeholder: 'Field name',
|
||||
field_type_placeholder: 'Select type',
|
||||
add_field: 'Add Field',
|
||||
custom_type_actions: {
|
||||
title: 'Actions',
|
||||
delete_custom_type: 'Delete',
|
||||
},
|
||||
delete_custom_type: 'Delete Type',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface DBCustomTypeFieldInfo {
|
||||
field: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export const DBCustomTypeFieldInfoSchema = z.object({
|
||||
field: z.string(),
|
||||
type: z.string(),
|
||||
});
|
||||
|
||||
export interface DBCustomTypeInfo {
|
||||
schema: string;
|
||||
type: string;
|
||||
kind: 'enum' | 'composite';
|
||||
values?: string[];
|
||||
fields?: DBCustomTypeFieldInfo[];
|
||||
}
|
||||
|
||||
export const DBCustomTypeInfoSchema: z.ZodType<DBCustomTypeInfo> = z.object({
|
||||
schema: z.string(),
|
||||
type: z.string(),
|
||||
kind: z.enum(['enum', 'composite']),
|
||||
values: z.array(z.string()).optional(),
|
||||
fields: z.array(DBCustomTypeFieldInfoSchema).optional(),
|
||||
});
|
||||
@@ -5,6 +5,10 @@ import { ColumnInfoSchema, type ColumnInfo } from './column-info';
|
||||
import { IndexInfoSchema, type IndexInfo } from './index-info';
|
||||
import { TableInfoSchema, type TableInfo } from './table-info';
|
||||
import { ViewInfoSchema, type ViewInfo } from './view-info';
|
||||
import {
|
||||
DBCustomTypeInfoSchema,
|
||||
type DBCustomTypeInfo,
|
||||
} from './custom-type-info';
|
||||
|
||||
export interface DatabaseMetadata {
|
||||
fk_info: ForeignKeyInfo[];
|
||||
@@ -13,6 +17,7 @@ export interface DatabaseMetadata {
|
||||
indexes: IndexInfo[];
|
||||
tables: TableInfo[];
|
||||
views: ViewInfo[];
|
||||
custom_types?: DBCustomTypeInfo[];
|
||||
database_name: string;
|
||||
version: string;
|
||||
}
|
||||
@@ -24,6 +29,7 @@ export const DatabaseMetadataSchema: z.ZodType<DatabaseMetadata> = z.object({
|
||||
indexes: z.array(IndexInfoSchema),
|
||||
tables: z.array(TableInfoSchema),
|
||||
views: z.array(ViewInfoSchema),
|
||||
custom_types: z.array(DBCustomTypeInfoSchema).optional(),
|
||||
database_name: z.string(),
|
||||
version: z.string(),
|
||||
});
|
||||
|
||||
@@ -255,6 +255,62 @@ cols AS (
|
||||
? supabaseViewsFilter
|
||||
: ''
|
||||
}
|
||||
), custom_types AS (
|
||||
SELECT array_to_string(array_agg(type_json), ',') AS custom_types_metadata
|
||||
FROM (
|
||||
-- ENUM types
|
||||
SELECT CONCAT(
|
||||
'{"schema":"', n.nspname,
|
||||
'","type":"', t.typname,
|
||||
'","kind":"enum"',
|
||||
',"values":[', string_agg('"' || e.enumlabel || '"', ',' ORDER BY e.enumsortorder), ']}'
|
||||
) AS type_json
|
||||
FROM pg_type t
|
||||
JOIN pg_enum e ON t.oid = e.enumtypid
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') ${
|
||||
databaseEdition === DatabaseEdition.POSTGRESQL_TIMESCALE
|
||||
? timescaleViewsFilter
|
||||
: databaseEdition === DatabaseEdition.POSTGRESQL_SUPABASE
|
||||
? supabaseViewsFilter
|
||||
: ''
|
||||
}
|
||||
GROUP BY n.nspname, t.typname
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- COMPOSITE types
|
||||
SELECT CONCAT(
|
||||
'{"schema":"', schema_name,
|
||||
'","type":"', type_name,
|
||||
'","kind":"composite"',
|
||||
',"fields":[', fields_json, ']}'
|
||||
) AS type_json
|
||||
FROM (
|
||||
SELECT
|
||||
n.nspname AS schema_name,
|
||||
t.typname AS type_name,
|
||||
string_agg(
|
||||
CONCAT('{"field":"', a.attname, '","type":"', format_type(a.atttypid, a.atttypmod), '"}'),
|
||||
',' ORDER BY a.attnum
|
||||
) AS fields_json
|
||||
FROM pg_type t
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
JOIN pg_class c ON c.oid = t.typrelid
|
||||
JOIN pg_attribute a ON a.attrelid = c.oid
|
||||
WHERE t.typtype = 'c'
|
||||
AND c.relkind = 'c' -- ✅ Only user-defined composite types
|
||||
AND a.attnum > 0 AND NOT a.attisdropped
|
||||
AND n.nspname NOT IN ('pg_catalog', 'information_schema') ${
|
||||
databaseEdition === DatabaseEdition.POSTGRESQL_TIMESCALE
|
||||
? timescaleViewsFilter
|
||||
: databaseEdition === DatabaseEdition.POSTGRESQL_SUPABASE
|
||||
? supabaseViewsFilter
|
||||
: ''
|
||||
}
|
||||
GROUP BY n.nspname, t.typname
|
||||
) AS comp
|
||||
) AS all_types
|
||||
)
|
||||
SELECT CONCAT('{ "fk_info": [', COALESCE(fk_metadata, ''),
|
||||
'], "pk_info": [', COALESCE(pk_metadata, ''),
|
||||
@@ -262,9 +318,10 @@ SELECT CONCAT('{ "fk_info": [', COALESCE(fk_metadata, ''),
|
||||
'], "indexes": [', COALESCE(indexes_metadata, ''),
|
||||
'], "tables":[', COALESCE(tbls_metadata, ''),
|
||||
'], "views":[', COALESCE(views_metadata, ''),
|
||||
'], "custom_types": [', COALESCE(custom_types_metadata, ''),
|
||||
'], "database_name": "', CURRENT_DATABASE(), '', '", "version": "', '',
|
||||
'"}') AS metadata_json_to_import
|
||||
FROM fk_info${databaseEdition ? '_' + databaseEdition : ''}, pk_info, cols, indexes_metadata, tbls, config, views;
|
||||
FROM fk_info${databaseEdition ? '_' + databaseEdition : ''}, pk_info, cols, indexes_metadata, tbls, config, views, custom_types;
|
||||
`;
|
||||
|
||||
const psqlPreCommand = `# *** Remember to change! (HOST_NAME, PORT, USER_NAME, DATABASE_NAME) *** \n`;
|
||||
|
||||
63
src/lib/domain/db-custom-type.ts
Normal file
63
src/lib/domain/db-custom-type.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { z } from 'zod';
|
||||
import type { DBCustomTypeInfo } from '@/lib/data/import-metadata/metadata-types/custom-type-info';
|
||||
import { generateId } from '../utils';
|
||||
import { schemaNameToDomainSchemaName } from './db-schema';
|
||||
|
||||
export enum DBCustomTypeKind {
|
||||
enum = 'enum',
|
||||
composite = 'composite',
|
||||
}
|
||||
|
||||
export interface DBCustomTypeField {
|
||||
field: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface DBCustomType {
|
||||
id: string;
|
||||
schema?: string;
|
||||
name: string;
|
||||
kind: DBCustomTypeKind;
|
||||
values?: string[]; // For enum types
|
||||
fields?: DBCustomTypeField[]; // For composite types
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export const dbCustomTypeFieldSchema = z.object({
|
||||
field: z.string(),
|
||||
type: z.string(),
|
||||
});
|
||||
|
||||
export const dbCustomTypeSchema: z.ZodType<DBCustomType> = z.object({
|
||||
id: z.string(),
|
||||
schema: z.string(),
|
||||
name: z.string(),
|
||||
kind: z.nativeEnum(DBCustomTypeKind),
|
||||
values: z.array(z.string()).optional(),
|
||||
fields: z.array(dbCustomTypeFieldSchema).optional(),
|
||||
});
|
||||
|
||||
export const createCustomTypesFromMetadata = ({
|
||||
customTypes,
|
||||
}: {
|
||||
customTypes: DBCustomTypeInfo[];
|
||||
}): DBCustomType[] => {
|
||||
return customTypes.map((customType) => {
|
||||
return {
|
||||
id: generateId(),
|
||||
schema: schemaNameToDomainSchemaName(customType.schema),
|
||||
name: customType.type,
|
||||
kind: customType.kind as DBCustomTypeKind,
|
||||
values: customType.values,
|
||||
fields: customType.fields,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const customTypeKindToLabel: Record<DBCustomTypeKind, string> = {
|
||||
enum: 'Enum',
|
||||
composite: 'Composite',
|
||||
};
|
||||
|
||||
export const getCustomTypeId = (name: string) =>
|
||||
`custom-type-${name.toLowerCase().trim()}`;
|
||||
@@ -1,11 +1,13 @@
|
||||
import { nanoid as generateId } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
import { dataTypeSchema, type DataType } from '../data/data-types/data-types';
|
||||
import type { ColumnInfo } from '../data/import-metadata/metadata-types/column-info';
|
||||
import type { AggregatedIndexInfo } from '../data/import-metadata/metadata-types/index-info';
|
||||
import type { PrimaryKeyInfo } from '../data/import-metadata/metadata-types/primary-key-info';
|
||||
import type { TableInfo } from '../data/import-metadata/metadata-types/table-info';
|
||||
import { generateId } from '../utils';
|
||||
import type { DBCustomTypeInfo } from '../data/import-metadata/metadata-types/custom-type-info';
|
||||
import { schemaNameToDomainSchemaName } from './db-schema';
|
||||
import { getCustomTypeId } from './db-custom-type';
|
||||
|
||||
export interface DBField {
|
||||
id: string;
|
||||
@@ -41,18 +43,63 @@ export const dbFieldSchema: z.ZodType<DBField> = z.object({
|
||||
comments: z.string().optional(),
|
||||
});
|
||||
|
||||
// Helper function to find the best matching custom type for a column
|
||||
const findMatchingCustomType = (
|
||||
columnName: string,
|
||||
customTypes: DBCustomTypeInfo[]
|
||||
): DBCustomTypeInfo | undefined => {
|
||||
// 1. Exact name match (highest priority)
|
||||
const exactMatch = customTypes.find((ct) => ct.type === columnName);
|
||||
if (exactMatch) return exactMatch;
|
||||
|
||||
// 2. Check if column name is the base of a custom type (e.g., 'role' matches 'role_enum')
|
||||
const prefixMatch = customTypes.find((ct) =>
|
||||
ct.type.startsWith(columnName + '_')
|
||||
);
|
||||
if (prefixMatch) return prefixMatch;
|
||||
|
||||
// 3. Check if a custom type name is the base of the column name (e.g., 'user_role' matches 'role_enum')
|
||||
const baseTypeMatch = customTypes.find((ct) => {
|
||||
// Extract base name by removing common suffixes
|
||||
const baseTypeName = ct.type.replace(/_enum$|_type$/, '');
|
||||
return (
|
||||
columnName.includes(baseTypeName) ||
|
||||
baseTypeName.includes(columnName)
|
||||
);
|
||||
});
|
||||
if (baseTypeMatch) return baseTypeMatch;
|
||||
|
||||
// 4. For composite types, check if any field matches the column name
|
||||
const compositeMatch = customTypes.find(
|
||||
(ct) =>
|
||||
ct.kind === 'composite' &&
|
||||
ct.fields?.some((f) => f.field === columnName)
|
||||
);
|
||||
if (compositeMatch) return compositeMatch;
|
||||
|
||||
// 5. Special case for name/fullname relationship which is common
|
||||
if (columnName === 'name') {
|
||||
const fullNameType = customTypes.find((ct) => ct.type === 'full_name');
|
||||
if (fullNameType) return fullNameType;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const createFieldsFromMetadata = ({
|
||||
columns,
|
||||
tableSchema,
|
||||
tableInfo,
|
||||
primaryKeys,
|
||||
aggregatedIndexes,
|
||||
customTypes = [],
|
||||
}: {
|
||||
columns: ColumnInfo[];
|
||||
tableSchema?: string;
|
||||
tableInfo: TableInfo;
|
||||
primaryKeys: PrimaryKeyInfo[];
|
||||
aggregatedIndexes: AggregatedIndexInfo[];
|
||||
customTypes?: DBCustomTypeInfo[];
|
||||
}) => {
|
||||
const uniqueColumns = columns
|
||||
.filter(
|
||||
@@ -79,14 +126,50 @@ export const createFieldsFromMetadata = ({
|
||||
)
|
||||
.map((pk) => pk.column.trim());
|
||||
|
||||
return sortedColumns.map(
|
||||
(col: ColumnInfo): DBField => ({
|
||||
id: generateId(),
|
||||
name: col.name,
|
||||
type: {
|
||||
// Create a mapping between column names and custom types
|
||||
const typeMap: Record<string, DBCustomTypeInfo> = {};
|
||||
|
||||
if (customTypes && customTypes.length > 0) {
|
||||
// Filter to custom types in this schema
|
||||
const schemaCustomTypes = customTypes.filter(
|
||||
(ct) => schemaNameToDomainSchemaName(ct.schema) === tableSchema
|
||||
);
|
||||
|
||||
// Process user-defined columns to find matching custom types
|
||||
for (const column of sortedColumns) {
|
||||
if (column.type.toLowerCase() === 'user-defined') {
|
||||
const matchingType = findMatchingCustomType(
|
||||
column.name,
|
||||
schemaCustomTypes
|
||||
);
|
||||
if (matchingType) {
|
||||
typeMap[column.name] = matchingType;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sortedColumns.map((col: ColumnInfo): DBField => {
|
||||
let type: DataType;
|
||||
|
||||
// Use custom type if available, otherwise use standard type
|
||||
if (col.type.toLowerCase() === 'user-defined' && typeMap[col.name]) {
|
||||
const customType = typeMap[col.name];
|
||||
type = {
|
||||
id: getCustomTypeId(customType.type),
|
||||
name: customType.type,
|
||||
};
|
||||
} else {
|
||||
type = {
|
||||
id: col.type.split(' ').join('_').toLowerCase(),
|
||||
name: col.type.toLowerCase(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
name: col.name,
|
||||
type,
|
||||
primaryKey: tablePrimaryKeys.includes(col.name),
|
||||
unique: Object.values(aggregatedIndexes).some(
|
||||
(idx) =>
|
||||
@@ -107,6 +190,6 @@ export const createFieldsFromMetadata = ({
|
||||
...(col.collation ? { collation: col.collation } : {}),
|
||||
createdAt: Date.now(),
|
||||
comments: col.comment ? col.comment : undefined,
|
||||
})
|
||||
);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -102,6 +102,7 @@ export const createTablesFromMetadata = ({
|
||||
columns,
|
||||
indexes,
|
||||
views: views,
|
||||
custom_types: customTypes,
|
||||
} = databaseMetadata;
|
||||
|
||||
return tableInfos.map((tableInfo: TableInfo) => {
|
||||
@@ -120,6 +121,10 @@ export const createTablesFromMetadata = ({
|
||||
primaryKeys,
|
||||
tableInfo,
|
||||
tableSchema,
|
||||
customTypes:
|
||||
databaseType === DatabaseType.POSTGRESQL
|
||||
? customTypes
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const dbIndexes = createIndexesFromMetadata({
|
||||
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
} from './db-table';
|
||||
import { generateDiagramId } from '@/lib/utils';
|
||||
import { areaSchema, type Area } from './area';
|
||||
import type { DBCustomType } from './db-custom-type';
|
||||
import {
|
||||
dbCustomTypeSchema,
|
||||
createCustomTypesFromMetadata,
|
||||
} from './db-custom-type';
|
||||
|
||||
export interface Diagram {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -29,6 +35,7 @@ export interface Diagram {
|
||||
relationships?: DBRelationship[];
|
||||
dependencies?: DBDependency[];
|
||||
areas?: Area[];
|
||||
customTypes?: DBCustomType[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -42,6 +49,7 @@ export const diagramSchema: z.ZodType<Diagram> = z.object({
|
||||
relationships: z.array(dbRelationshipSchema).optional(),
|
||||
dependencies: z.array(dbDependencySchema).optional(),
|
||||
areas: z.array(areaSchema).optional(),
|
||||
customTypes: z.array(dbCustomTypeSchema).optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
@@ -57,7 +65,11 @@ export const loadFromDatabaseMetadata = async ({
|
||||
diagramNumber?: number;
|
||||
databaseEdition?: DatabaseEdition;
|
||||
}): Promise<Diagram> => {
|
||||
const { fk_info: foreignKeys, views: views } = databaseMetadata;
|
||||
const {
|
||||
fk_info: foreignKeys,
|
||||
views: views,
|
||||
custom_types: customTypes,
|
||||
} = databaseMetadata;
|
||||
|
||||
const tables = createTablesFromMetadata({
|
||||
databaseMetadata,
|
||||
@@ -75,6 +87,12 @@ export const loadFromDatabaseMetadata = async ({
|
||||
databaseType,
|
||||
});
|
||||
|
||||
const dbCustomTypes = customTypes
|
||||
? createCustomTypesFromMetadata({
|
||||
customTypes,
|
||||
})
|
||||
: [];
|
||||
|
||||
const adjustedTables = adjustTablePositions({
|
||||
tables,
|
||||
relationships,
|
||||
@@ -102,6 +120,7 @@ export const loadFromDatabaseMetadata = async ({
|
||||
tables: sortedTables,
|
||||
relationships,
|
||||
dependencies,
|
||||
customTypes: dbCustomTypes,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/sidebar/sidebar';
|
||||
import { Twitter, BookOpen, Group } from 'lucide-react';
|
||||
import { Twitter, BookOpen, Group, FileType } from 'lucide-react';
|
||||
import { SquareStack, Table, Workflow } from 'lucide-react';
|
||||
import { useLayout } from '@/hooks/use-layout';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -20,6 +20,7 @@ import ChartDBLogo from '@/assets/logo-light.png';
|
||||
import ChartDBDarkLogo from '@/assets/logo-dark.png';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import { DatabaseType } from '@/lib/domain/database-type';
|
||||
|
||||
export interface SidebarItem {
|
||||
title: string;
|
||||
@@ -37,7 +38,7 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isMd: isDesktop } = useBreakpoint('md');
|
||||
const { effectiveTheme } = useTheme();
|
||||
const { dependencies } = useChartDB();
|
||||
const { dependencies, databaseType } = useChartDB();
|
||||
|
||||
const items: SidebarItem[] = useMemo(() => {
|
||||
const baseItems = [
|
||||
@@ -68,20 +69,38 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = () => {
|
||||
},
|
||||
active: selectedSidebarSection === 'areas',
|
||||
},
|
||||
...(dependencies && dependencies.length > 0
|
||||
? [
|
||||
{
|
||||
title: t(
|
||||
'side_panel.dependencies_section.dependencies'
|
||||
),
|
||||
icon: SquareStack,
|
||||
onClick: () => {
|
||||
showSidePanel();
|
||||
selectSidebarSection('dependencies');
|
||||
},
|
||||
active: selectedSidebarSection === 'dependencies',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(databaseType === DatabaseType.POSTGRESQL
|
||||
? [
|
||||
{
|
||||
title: t(
|
||||
'side_panel.custom_types_section.custom_types'
|
||||
),
|
||||
icon: FileType,
|
||||
onClick: () => {
|
||||
showSidePanel();
|
||||
selectSidebarSection('customTypes');
|
||||
},
|
||||
active: selectedSidebarSection === 'customTypes',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
if (dependencies && dependencies.length > 0) {
|
||||
baseItems.splice(2, 0, {
|
||||
title: t('side_panel.dependencies_section.dependencies'),
|
||||
icon: SquareStack,
|
||||
onClick: () => {
|
||||
showSidePanel();
|
||||
selectSidebarSection('dependencies');
|
||||
},
|
||||
active: selectedSidebarSection === 'dependencies',
|
||||
});
|
||||
}
|
||||
|
||||
return baseItems;
|
||||
}, [
|
||||
selectSidebarSection,
|
||||
@@ -89,6 +108,7 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = () => {
|
||||
t,
|
||||
showSidePanel,
|
||||
dependencies,
|
||||
databaseType,
|
||||
]);
|
||||
|
||||
const footerItems: SidebarItem[] = useMemo(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { X, GripVertical } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/button/button';
|
||||
import type { DBCustomTypeField } from '@/lib/domain/db-custom-type';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
export interface CompositeFieldProps {
|
||||
field: DBCustomTypeField;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export const CompositeField: React.FC<{
|
||||
field: DBCustomTypeField;
|
||||
onRemove: () => void;
|
||||
}> = ({ field, onRemove }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: field.field });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="flex items-center gap-2 rounded-md border p-2"
|
||||
>
|
||||
<div
|
||||
className="flex cursor-move items-center justify-center"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-3 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 text-sm">{field.field}</div>
|
||||
<div className="text-xs text-muted-foreground">{field.type}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-6 p-0 text-muted-foreground hover:text-red-500"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
import { Plus, RectangleEllipsis } from 'lucide-react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '@/components/input/input';
|
||||
import { Button } from '@/components/button/button';
|
||||
import type { DBCustomTypeField } from '@/lib/domain/db-custom-type';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/select/select';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import type { DataTypeData } from '@/lib/data/data-types/data-types';
|
||||
import { dataTypeMap } from '@/lib/data/data-types/data-types';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CompositeField } from './composite-field';
|
||||
|
||||
export interface CustomTypeCompositeFieldsProps {
|
||||
fields: DBCustomTypeField[];
|
||||
addField: (value: DBCustomTypeField) => void;
|
||||
removeField: (value: DBCustomTypeField) => void;
|
||||
reorderFields: (fields: DBCustomTypeField[]) => void;
|
||||
}
|
||||
|
||||
export const CustomTypeCompositeFields: React.FC<
|
||||
CustomTypeCompositeFieldsProps
|
||||
> = ({ fields, addField, removeField, reorderFields }) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentDiagram, customTypes } = useChartDB();
|
||||
const [newFieldName, setNewFieldName] = useState('');
|
||||
const [newFieldType, setNewFieldType] = useState('');
|
||||
|
||||
const dataTypes = useMemo(
|
||||
() => dataTypeMap[currentDiagram.databaseType] || [],
|
||||
[currentDiagram.databaseType]
|
||||
);
|
||||
|
||||
const customDataTypes = useMemo<DataTypeData[]>(
|
||||
() =>
|
||||
customTypes.map<DataTypeData>((type) => ({
|
||||
id: `custom-type-${type.id}`,
|
||||
name: type.name,
|
||||
})),
|
||||
[customTypes]
|
||||
);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (active?.id !== over?.id && !!over && !!active) {
|
||||
const oldIndex = fields.findIndex(
|
||||
(field) => field.field === active.id
|
||||
);
|
||||
const newIndex = fields.findIndex(
|
||||
(field) => field.field === over.id
|
||||
);
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
const reorderedFields = arrayMove(
|
||||
fields,
|
||||
oldIndex,
|
||||
newIndex
|
||||
);
|
||||
reorderFields(reorderedFields);
|
||||
}
|
||||
}
|
||||
},
|
||||
[fields, reorderFields]
|
||||
);
|
||||
|
||||
const handleAddField = useCallback(() => {
|
||||
if (newFieldName.trim() && newFieldType.trim()) {
|
||||
// Check if field name already exists
|
||||
const fieldExists = fields.some(
|
||||
(field) => field.field === newFieldName.trim()
|
||||
);
|
||||
if (fieldExists) {
|
||||
return; // Don't add duplicate field names
|
||||
}
|
||||
|
||||
addField({
|
||||
field: newFieldName.trim(),
|
||||
type: newFieldType.trim(),
|
||||
});
|
||||
setNewFieldName('');
|
||||
setNewFieldType('');
|
||||
}
|
||||
}, [newFieldName, newFieldType, addField, fields]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddField();
|
||||
}
|
||||
},
|
||||
[handleAddField]
|
||||
);
|
||||
|
||||
const handleRemoveField = useCallback(
|
||||
(field: DBCustomTypeField) => {
|
||||
removeField(field);
|
||||
},
|
||||
[removeField]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 text-xs">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<RectangleEllipsis className="size-4 text-subtitle" />
|
||||
<div className="font-bold text-subtitle">
|
||||
{t(
|
||||
'side_panel.custom_types_section.custom_type.composite_fields'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 ? (
|
||||
<div className="py-2 text-muted-foreground">
|
||||
{t('side_panel.custom_types_section.custom_type.no_fields')}
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={fields.map((f) => f.field)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{fields.map((field) => (
|
||||
<CompositeField
|
||||
key={field.field}
|
||||
field={field}
|
||||
onRemove={() => handleRemoveField(field)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t(
|
||||
'side_panel.custom_types_section.custom_type.field_name_placeholder'
|
||||
)}
|
||||
value={newFieldName}
|
||||
onChange={(e) => setNewFieldName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
<Select
|
||||
value={newFieldType}
|
||||
onValueChange={setNewFieldType}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-32 text-xs">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
'side_panel.custom_types_section.custom_type.field_type_placeholder'
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Standard Types</SelectLabel>
|
||||
{dataTypes.map((dataType) => (
|
||||
<SelectItem
|
||||
key={dataType.id}
|
||||
value={dataType.name}
|
||||
>
|
||||
{dataType.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
{customDataTypes.length > 0 ? (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Custom Types</SelectLabel>
|
||||
{customDataTypes.map((dataType) => (
|
||||
<SelectItem
|
||||
key={dataType.id}
|
||||
value={dataType.name}
|
||||
>
|
||||
{dataType.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1 self-start text-xs"
|
||||
onClick={handleAddField}
|
||||
disabled={!newFieldName.trim() || !newFieldType.trim()}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{t('side_panel.custom_types_section.custom_type.add_field')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Button } from '@/components/button/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/select/select';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import type {
|
||||
DBCustomType,
|
||||
DBCustomTypeField,
|
||||
} from '@/lib/domain/db-custom-type';
|
||||
import {
|
||||
customTypeKindToLabel,
|
||||
DBCustomTypeKind,
|
||||
} from '@/lib/domain/db-custom-type';
|
||||
import { Trash2, Braces } from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CustomTypeEnumValues } from './enum-values/enum-values';
|
||||
import { CustomTypeCompositeFields } from './composite-fields/composite-fields';
|
||||
|
||||
export interface CustomTypeListItemContentProps {
|
||||
customType: DBCustomType;
|
||||
}
|
||||
|
||||
export const CustomTypeListItemContent: React.FC<
|
||||
CustomTypeListItemContentProps
|
||||
> = ({ customType }) => {
|
||||
const { removeCustomType, updateCustomType } = useChartDB();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const deleteCustomTypeHandler = useCallback(() => {
|
||||
removeCustomType(customType.id);
|
||||
}, [customType.id, removeCustomType]);
|
||||
|
||||
const updateCustomTypeKind = useCallback(
|
||||
(kind: DBCustomTypeKind) => {
|
||||
updateCustomType(customType.id, {
|
||||
kind,
|
||||
});
|
||||
},
|
||||
[customType.id, updateCustomType]
|
||||
);
|
||||
|
||||
const addEnumValue = useCallback(
|
||||
(value: string) => {
|
||||
updateCustomType(customType.id, {
|
||||
values: [...(customType.values || []), value],
|
||||
});
|
||||
},
|
||||
[customType.id, customType.values, updateCustomType]
|
||||
);
|
||||
|
||||
const removeEnumValue = useCallback(
|
||||
(value: string) => {
|
||||
updateCustomType(customType.id, {
|
||||
values: (customType.values || []).filter((v) => v !== value),
|
||||
});
|
||||
},
|
||||
[customType.id, customType.values, updateCustomType]
|
||||
);
|
||||
|
||||
const addCompositeField = useCallback(
|
||||
(field: DBCustomTypeField) => {
|
||||
updateCustomType(customType.id, {
|
||||
fields: [...(customType.fields || []), field],
|
||||
});
|
||||
},
|
||||
[customType.id, customType.fields, updateCustomType]
|
||||
);
|
||||
|
||||
const removeCompositeField = useCallback(
|
||||
(field: DBCustomTypeField) => {
|
||||
updateCustomType(customType.id, {
|
||||
fields: (customType.fields || []).filter(
|
||||
(f) => f.field !== field.field
|
||||
),
|
||||
});
|
||||
},
|
||||
[customType.id, customType.fields, updateCustomType]
|
||||
);
|
||||
|
||||
const reorderCompositeFields = useCallback(
|
||||
(fields: DBCustomTypeField[]) => {
|
||||
updateCustomType(customType.id, {
|
||||
fields,
|
||||
});
|
||||
},
|
||||
[customType.id, updateCustomType]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="my-1 flex flex-col rounded-b-md px-1">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2 text-xs">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<Braces className="size-4 text-subtitle" />
|
||||
<div className="font-bold text-subtitle">
|
||||
{t(
|
||||
'side_panel.custom_types_section.custom_type.kind'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={customType.kind}
|
||||
onValueChange={updateCustomTypeKind}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value={DBCustomTypeKind.composite}>
|
||||
{
|
||||
customTypeKindToLabel[
|
||||
DBCustomTypeKind.composite
|
||||
]
|
||||
}
|
||||
</SelectItem>
|
||||
<SelectItem value={DBCustomTypeKind.enum}>
|
||||
{
|
||||
customTypeKindToLabel[
|
||||
DBCustomTypeKind.enum
|
||||
]
|
||||
}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{customType.kind === DBCustomTypeKind.enum ? (
|
||||
<CustomTypeEnumValues
|
||||
values={customType.values || []}
|
||||
addValue={addEnumValue}
|
||||
removeValue={removeEnumValue}
|
||||
/>
|
||||
) : (
|
||||
<CustomTypeCompositeFields
|
||||
fields={customType.fields || []}
|
||||
addField={addCompositeField}
|
||||
removeField={removeCompositeField}
|
||||
reorderFields={reorderCompositeFields}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 p-2 text-xs"
|
||||
onClick={deleteCustomTypeHandler}
|
||||
>
|
||||
<Trash2 className="mr-1 size-3.5 text-red-700" />
|
||||
<div className="text-red-700">
|
||||
{t(
|
||||
'side_panel.custom_types_section.custom_type.delete_custom_type'
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Pause, Plus, X } from 'lucide-react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '@/components/input/input';
|
||||
import { Button } from '@/components/button/button';
|
||||
|
||||
export interface EnumValuesProps {
|
||||
values: string[];
|
||||
addValue: (value: string) => void;
|
||||
removeValue: (value: string) => void;
|
||||
}
|
||||
|
||||
export const CustomTypeEnumValues: React.FC<EnumValuesProps> = ({
|
||||
values,
|
||||
addValue,
|
||||
removeValue,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [newValue, setNewValue] = useState('');
|
||||
|
||||
const handleAddValue = useCallback(() => {
|
||||
if (newValue.trim() && !values.includes(newValue.trim())) {
|
||||
addValue(newValue.trim());
|
||||
setNewValue('');
|
||||
}
|
||||
}, [newValue, values, addValue]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddValue();
|
||||
}
|
||||
},
|
||||
[handleAddValue]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 text-xs">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<Pause className="size-4 text-subtitle" />
|
||||
<div className="font-bold text-subtitle">
|
||||
{t(
|
||||
'side_panel.custom_types_section.custom_type.enum_values'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
{values.map((value, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between gap-1 rounded-md border border-border bg-muted/30 px-2 py-1"
|
||||
>
|
||||
<span className="flex-1 truncate text-sm font-medium">
|
||||
{value}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-6 p-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => removeValue(value)}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add enum value..."
|
||||
className="h-8 flex-1 text-xs focus-visible:ring-0"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-2 text-xs"
|
||||
onClick={handleAddValue}
|
||||
disabled={
|
||||
!newValue.trim() || values.includes(newValue.trim())
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import {
|
||||
GripVertical,
|
||||
Pencil,
|
||||
EllipsisVertical,
|
||||
Trash2,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { ListItemHeaderButton } from '@/pages/editor-page/side-panel/list-item-header-button/list-item-header-button';
|
||||
import { Input } from '@/components/input/input';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import { useClickAway, useKeyPressEvent } from 'react-use';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/dropdown-menu/dropdown-menu';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/tooltip/tooltip';
|
||||
import {
|
||||
customTypeKindToLabel,
|
||||
DBCustomTypeKind,
|
||||
type DBCustomType,
|
||||
} from '@/lib/domain/db-custom-type';
|
||||
import { Badge } from '@/components/badge/badge';
|
||||
|
||||
export interface CustomTypeListItemHeaderProps {
|
||||
customType: DBCustomType;
|
||||
}
|
||||
|
||||
export const CustomTypeListItemHeader: React.FC<
|
||||
CustomTypeListItemHeaderProps
|
||||
> = ({ customType }) => {
|
||||
const { updateCustomType, removeCustomType, schemas, filteredSchemas } =
|
||||
useChartDB();
|
||||
const { t } = useTranslation();
|
||||
const [editMode, setEditMode] = React.useState(false);
|
||||
const [customTypeName, setCustomTypeName] = React.useState(customType.name);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const { listeners } = useSortable({ id: customType.id });
|
||||
|
||||
const editCustomTypeName = useCallback(() => {
|
||||
if (!editMode) return;
|
||||
if (customTypeName.trim()) {
|
||||
updateCustomType(customType.id, { name: customTypeName.trim() });
|
||||
}
|
||||
|
||||
setEditMode(false);
|
||||
}, [customTypeName, customType.id, updateCustomType, editMode]);
|
||||
|
||||
const abortEdit = useCallback(() => {
|
||||
setEditMode(false);
|
||||
setCustomTypeName(customType.name);
|
||||
}, [customType.name]);
|
||||
|
||||
useClickAway(inputRef, editCustomTypeName);
|
||||
useKeyPressEvent('Enter', editCustomTypeName);
|
||||
useKeyPressEvent('Escape', abortEdit);
|
||||
|
||||
const enterEditMode = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditMode(true);
|
||||
};
|
||||
|
||||
const deleteCustomTypeHandler = useCallback(() => {
|
||||
removeCustomType(customType.id);
|
||||
}, [customType.id, removeCustomType]);
|
||||
|
||||
const renderDropDownMenu = useCallback(
|
||||
() => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<ListItemHeaderButton>
|
||||
<EllipsisVertical />
|
||||
</ListItemHeaderButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-fit min-w-40">
|
||||
<DropdownMenuLabel>
|
||||
{t(
|
||||
'side_panel.custom_types_section.custom_type.custom_type_actions.title'
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={deleteCustomTypeHandler}
|
||||
className="flex justify-between !text-red-700"
|
||||
>
|
||||
{t(
|
||||
'side_panel.custom_types_section.custom_type.custom_type_actions.delete_custom_type'
|
||||
)}
|
||||
<Trash2 className="size-3.5 text-red-700" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
[deleteCustomTypeHandler, t]
|
||||
);
|
||||
|
||||
let schemaToDisplay;
|
||||
|
||||
if (schemas.length > 1 && !!filteredSchemas && filteredSchemas.length > 1) {
|
||||
schemaToDisplay = customType.schema;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group flex h-11 flex-1 items-center justify-between gap-1 overflow-hidden">
|
||||
<div
|
||||
className="flex cursor-move items-center justify-center"
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 px-1">
|
||||
{editMode ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={customType.name}
|
||||
value={customTypeName}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setCustomTypeName(e.target.value)}
|
||||
className="h-7 w-full focus-visible:ring-0"
|
||||
/>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onDoubleClick={enterEditMode}
|
||||
className="text-editable truncate px-2 py-0.5"
|
||||
>
|
||||
{customType.name}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{schemaToDisplay
|
||||
? ` (${schemaToDisplay})`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('tool_tips.double_click_to_edit')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row-reverse items-center">
|
||||
{!editMode ? (
|
||||
<>
|
||||
<div>{renderDropDownMenu()}</div>
|
||||
{customType.kind === DBCustomTypeKind.enum ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-fit bg-background px-2 text-xs md:group-hover:hidden"
|
||||
>
|
||||
{customTypeKindToLabel[customType.kind]}
|
||||
</Badge>
|
||||
) : null}
|
||||
<div className="flex flex-row-reverse md:hidden md:group-hover:flex">
|
||||
<ListItemHeaderButton onClick={enterEditMode}>
|
||||
<Pencil />
|
||||
</ListItemHeaderButton>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ListItemHeaderButton onClick={editCustomTypeName}>
|
||||
<Check />
|
||||
</ListItemHeaderButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/accordion/accordion';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import type { DBCustomType } from '@/lib/domain/db-custom-type';
|
||||
import { CustomTypeListItemHeader } from './custom-type-list-item-header/custom-type-list-item-header';
|
||||
import { CustomTypeListItemContent } from './custom-type-list-item-content/custom-type-list-item-content';
|
||||
|
||||
export interface CustomTypeListItemProps {
|
||||
customType: DBCustomType;
|
||||
}
|
||||
|
||||
export const CustomTypeListItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionItem>,
|
||||
CustomTypeListItemProps
|
||||
>(({ customType }, ref) => {
|
||||
const { attributes, setNodeRef, transform, transition } = useSortable({
|
||||
id: customType.id,
|
||||
});
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<AccordionItem value={customType.id} className="border-none" ref={ref}>
|
||||
<div
|
||||
className="w-full rounded-md border-b"
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
>
|
||||
<AccordionTrigger
|
||||
className="w-full rounded-md bg-slate-50 px-2 py-0 hover:bg-accent hover:no-underline data-[state=open]:rounded-b-none dark:bg-slate-900"
|
||||
asChild
|
||||
>
|
||||
<CustomTypeListItemHeader customType={customType} />
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="border-x border-slate-100 p-1 pb-0 dark:border-slate-800">
|
||||
<CustomTypeListItemContent customType={customType} />
|
||||
</AccordionContent>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
);
|
||||
});
|
||||
|
||||
CustomTypeListItem.displayName = 'CustomTypeListItem';
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Accordion } from '@/components/accordion/accordion';
|
||||
import { useLayout } from '@/hooks/use-layout';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { useChartDB } from '@/hooks/use-chartdb.ts';
|
||||
import type { DBCustomType } from '@/lib/domain/db-custom-type';
|
||||
import { CustomTypeListItem } from './custom-type-list-item/custom-type-list-item';
|
||||
|
||||
export interface CustomTypeProps {
|
||||
customTypes: DBCustomType[];
|
||||
}
|
||||
|
||||
export const CustomTypeList: React.FC<CustomTypeProps> = ({ customTypes }) => {
|
||||
const { updateCustomType } = useChartDB();
|
||||
|
||||
const { openCustomTypeFromSidebar, openedCustomTypeInSidebar } =
|
||||
useLayout();
|
||||
const lastOpenedCustomType = React.useRef<string | null>(null);
|
||||
const refs = useMemo(
|
||||
() =>
|
||||
customTypes.reduce(
|
||||
(acc, customType) => {
|
||||
acc[customType.id] = React.createRef();
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, React.RefObject<HTMLDivElement>>
|
||||
),
|
||||
[customTypes]
|
||||
);
|
||||
|
||||
const scrollToCustomType = useCallback(
|
||||
(id: string) =>
|
||||
refs[id]?.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
}),
|
||||
[refs]
|
||||
);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (active?.id !== over?.id && !!over && !!active) {
|
||||
const oldIndex = customTypes.findIndex(
|
||||
(customType) => customType.id === active.id
|
||||
);
|
||||
const newIndex = customTypes.findIndex(
|
||||
(customType) => customType.id === over.id
|
||||
);
|
||||
|
||||
const newCustomTypesOrder = arrayMove<DBCustomType>(
|
||||
customTypes,
|
||||
oldIndex,
|
||||
newIndex
|
||||
);
|
||||
|
||||
newCustomTypesOrder.forEach((customType, index) => {
|
||||
updateCustomType(customType.id, { order: index });
|
||||
});
|
||||
}
|
||||
},
|
||||
[customTypes, updateCustomType]
|
||||
);
|
||||
|
||||
const handleScrollToCustomType = useCallback(() => {
|
||||
if (
|
||||
openedCustomTypeInSidebar &&
|
||||
lastOpenedCustomType.current !== openedCustomTypeInSidebar
|
||||
) {
|
||||
lastOpenedCustomType.current = openedCustomTypeInSidebar;
|
||||
scrollToCustomType(openedCustomTypeInSidebar);
|
||||
}
|
||||
}, [scrollToCustomType, openedCustomTypeInSidebar]);
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="flex w-full flex-col gap-1"
|
||||
value={openedCustomTypeInSidebar}
|
||||
onValueChange={openCustomTypeFromSidebar}
|
||||
onAnimationEnd={handleScrollToCustomType}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={customTypes}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{customTypes
|
||||
.sort(
|
||||
(
|
||||
customType1: DBCustomType,
|
||||
customType2: DBCustomType
|
||||
) => {
|
||||
// if one has order and the other doesn't, the one with order should come first
|
||||
if (
|
||||
customType1.order &&
|
||||
customType2.order === undefined
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (
|
||||
customType1.order === undefined &&
|
||||
customType2.order
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// if both have order, sort by order
|
||||
if (
|
||||
customType1.order !== undefined &&
|
||||
customType2.order !== undefined
|
||||
) {
|
||||
return (
|
||||
customType1.order - customType2.order
|
||||
);
|
||||
}
|
||||
|
||||
// sort by name
|
||||
return customType1.name.localeCompare(
|
||||
customType2.name
|
||||
);
|
||||
}
|
||||
)
|
||||
.map((customType) => (
|
||||
<CustomTypeListItem
|
||||
key={customType.id}
|
||||
customType={customType}
|
||||
ref={refs[customType.id]}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Button } from '@/components/button/button';
|
||||
import { X, Plus } from 'lucide-react';
|
||||
import { Input } from '@/components/input/input';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import { EmptyState } from '@/components/empty-state/empty-state';
|
||||
import { ScrollArea } from '@/components/scroll-area/scroll-area';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { getOperatingSystem } from '@/lib/utils';
|
||||
import { CustomTypeList } from './custom-type-list/custom-type-list';
|
||||
import { DatabaseType } from '@/lib/domain/database-type';
|
||||
|
||||
export interface CustomTypesSectionProps {}
|
||||
|
||||
export const CustomTypesSection: React.FC<CustomTypesSectionProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const { customTypes, createCustomType, databaseType } = useChartDB();
|
||||
const [filterText, setFilterText] = React.useState('');
|
||||
const filterInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const isPostgres = databaseType === DatabaseType.POSTGRESQL;
|
||||
|
||||
const filteredCustomTypes = useMemo(() => {
|
||||
return customTypes.filter(
|
||||
(type) =>
|
||||
!filterText?.trim?.() ||
|
||||
type.name.toLowerCase().includes(filterText.toLowerCase())
|
||||
);
|
||||
}, [customTypes, filterText]);
|
||||
|
||||
const handleClearFilter = useCallback(() => {
|
||||
setFilterText('');
|
||||
}, []);
|
||||
|
||||
const handleCreateCustomType = useCallback(async () => {
|
||||
await createCustomType();
|
||||
}, [createCustomType]);
|
||||
|
||||
const operatingSystem = useMemo(() => getOperatingSystem(), []);
|
||||
|
||||
useHotkeys(
|
||||
operatingSystem === 'mac' ? 'meta+f' : 'ctrl+f',
|
||||
() => {
|
||||
filterInputRef.current?.focus();
|
||||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
},
|
||||
[filterInputRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-1 flex-col overflow-hidden px-2"
|
||||
data-vaul-no-drag
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 py-1">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
ref={filterInputRef}
|
||||
type="text"
|
||||
placeholder={t(
|
||||
'side_panel.custom_types_section.filter'
|
||||
)}
|
||||
className="h-8 w-full focus-visible:ring-0"
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{isPostgres && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
onClick={handleCreateCustomType}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
New Type
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
{customTypes.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t(
|
||||
'side_panel.custom_types_section.empty_state.title'
|
||||
)}
|
||||
description={t(
|
||||
'side_panel.custom_types_section.empty_state.description'
|
||||
)}
|
||||
className="mt-20"
|
||||
/>
|
||||
) : filterText && filteredCustomTypes.length === 0 ? (
|
||||
<div className="mt-10 flex flex-col items-center gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
'side_panel.custom_types_section.no_results'
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearFilter}
|
||||
className="gap-1"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
{t('side_panel.custom_types_section.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<CustomTypeList customTypes={filteredCustomTypes} />
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/tooltip/tooltip';
|
||||
import { useDialog } from '@/hooks/use-dialog';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { getOperatingSystem } from '@/lib/utils';
|
||||
|
||||
export interface RelationshipsSectionProps {}
|
||||
|
||||
@@ -25,6 +27,7 @@ export const RelationshipsSection: React.FC<RelationshipsSectionProps> = () => {
|
||||
const { closeAllRelationshipsInSidebar } = useLayout();
|
||||
const { t } = useTranslation();
|
||||
const { openCreateRelationshipDialog } = useDialog();
|
||||
const filterInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const filteredRelationships = useMemo(() => {
|
||||
const filterName: (relationship: DBRelationship) => boolean = (
|
||||
@@ -46,6 +49,19 @@ export const RelationshipsSection: React.FC<RelationshipsSectionProps> = () => {
|
||||
openCreateRelationshipDialog();
|
||||
}, [openCreateRelationshipDialog, setFilterText]);
|
||||
|
||||
const operatingSystem = useMemo(() => getOperatingSystem(), []);
|
||||
|
||||
useHotkeys(
|
||||
operatingSystem === 'mac' ? 'meta+f' : 'ctrl+f',
|
||||
() => {
|
||||
filterInputRef.current?.focus();
|
||||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
},
|
||||
[filterInputRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="flex flex-1 flex-col overflow-hidden px-2">
|
||||
<div className="flex items-center justify-between gap-4 py-1">
|
||||
@@ -69,6 +85,7 @@ export const RelationshipsSection: React.FC<RelationshipsSectionProps> = () => {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
ref={filterInputRef}
|
||||
type="text"
|
||||
placeholder={t(
|
||||
'side_panel.relationships_section.filter'
|
||||
|
||||
@@ -18,12 +18,15 @@ import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import { DependenciesSection } from './dependencies-section/dependencies-section';
|
||||
import { useBreakpoint } from '@/hooks/use-breakpoint';
|
||||
import { AreasSection } from './areas-section/areas-section';
|
||||
import { CustomTypesSection } from './custom-types-section/custom-types-section';
|
||||
import { DatabaseType } from '@/lib/domain/database-type';
|
||||
|
||||
export interface SidePanelProps {}
|
||||
|
||||
export const SidePanel: React.FC<SidePanelProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const { schemas, filterSchemas, filteredSchemas } = useChartDB();
|
||||
const { schemas, filterSchemas, filteredSchemas, databaseType } =
|
||||
useChartDB();
|
||||
const {
|
||||
selectSidebarSection,
|
||||
selectedSidebarSection,
|
||||
@@ -117,6 +120,13 @@ export const SidePanel: React.FC<SidePanelProps> = () => {
|
||||
<SelectItem value="areas">
|
||||
{t('side_panel.areas_section.areas')}
|
||||
</SelectItem>
|
||||
{databaseType === DatabaseType.POSTGRESQL ? (
|
||||
<SelectItem value="customTypes">
|
||||
{t(
|
||||
'side_panel.custom_types_section.custom_types'
|
||||
)}
|
||||
</SelectItem>
|
||||
) : null}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -128,8 +138,10 @@ export const SidePanel: React.FC<SidePanelProps> = () => {
|
||||
<RelationshipsSection />
|
||||
) : selectedSidebarSection === 'dependencies' ? (
|
||||
<DependenciesSection />
|
||||
) : (
|
||||
) : selectedSidebarSection === 'areas' ? (
|
||||
<AreasSection />
|
||||
) : (
|
||||
<CustomTypesSection />
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { GripVertical, KeyRound } from 'lucide-react';
|
||||
import { Input } from '@/components/input/input';
|
||||
import type { DBField } from '@/lib/domain/db-field';
|
||||
@@ -6,6 +6,7 @@ import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import {
|
||||
dataTypeDataToDataType,
|
||||
sortedDataTypeMap,
|
||||
type DataType,
|
||||
} from '@/lib/data/data-types/data-types';
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -34,51 +35,98 @@ export const TableField: React.FC<TableFieldProps> = ({
|
||||
updateField,
|
||||
removeField,
|
||||
}) => {
|
||||
const { databaseType } = useChartDB();
|
||||
const { databaseType, customTypes } = useChartDB();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: field.id });
|
||||
|
||||
const dataFieldOptions: SelectBoxOption[] = sortedDataTypeMap[
|
||||
databaseType
|
||||
].map((type) => ({
|
||||
label: type.name,
|
||||
value: type.id,
|
||||
regex: type.hasCharMaxLength ? `^${type.name}\\(\\d+\\)$` : undefined,
|
||||
extractRegex: type.hasCharMaxLength ? /\((\d+)\)/ : undefined,
|
||||
}));
|
||||
const dataFieldOptions = useMemo(() => {
|
||||
// Start with the built-in data types
|
||||
const standardTypes: SelectBoxOption[] = sortedDataTypeMap[
|
||||
databaseType
|
||||
].map((type) => ({
|
||||
label: type.name,
|
||||
value: type.id,
|
||||
regex: type.hasCharMaxLength
|
||||
? `^${type.name}\\(\\d+\\)$`
|
||||
: undefined,
|
||||
extractRegex: type.hasCharMaxLength ? /\((\d+)\)/ : undefined,
|
||||
group: 'Standard Types',
|
||||
}));
|
||||
|
||||
if (!customTypes?.length) {
|
||||
return standardTypes;
|
||||
}
|
||||
|
||||
// Add custom types as options
|
||||
const customTypeOptions: SelectBoxOption[] = customTypes.map(
|
||||
(type) => ({
|
||||
label: type.name,
|
||||
value: `custom-type-${type.name}`,
|
||||
// Add additional info to distinguish custom types
|
||||
description:
|
||||
type.kind === 'enum' ? `${type.values?.join(' | ')}` : '',
|
||||
// Group custom types together
|
||||
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,
|
||||
};
|
||||
// Check if this is a custom type
|
||||
const isCustomType =
|
||||
typeof value === 'string' && value.startsWith('custom-type-');
|
||||
|
||||
let newType: DataType;
|
||||
|
||||
if (isCustomType && typeof value === 'string') {
|
||||
// For custom types, create a custom DataType object
|
||||
const typeName = value.replace('custom-type-', '');
|
||||
newType = {
|
||||
id: value as string,
|
||||
name: typeName,
|
||||
};
|
||||
} else {
|
||||
// For standard types, use the existing logic
|
||||
const dataType = sortedDataTypeMap[databaseType].find(
|
||||
(v) => v.id === value
|
||||
) ?? {
|
||||
id: value as string,
|
||||
name: value as string,
|
||||
};
|
||||
|
||||
newType = dataTypeDataToDataType(dataType);
|
||||
}
|
||||
|
||||
let characterMaximumLength: string | undefined = undefined;
|
||||
|
||||
if (regexMatches?.length && dataType?.hasCharMaxLength) {
|
||||
characterMaximumLength = regexMatches[1];
|
||||
} else if (
|
||||
field.characterMaximumLength &&
|
||||
dataType?.hasCharMaxLength
|
||||
) {
|
||||
characterMaximumLength = field.characterMaximumLength;
|
||||
if (regexMatches?.length && !isCustomType) {
|
||||
const dataType = sortedDataTypeMap[databaseType].find(
|
||||
(v) => v.id === value
|
||||
);
|
||||
|
||||
if (dataType?.hasCharMaxLength) {
|
||||
characterMaximumLength = regexMatches[1];
|
||||
}
|
||||
} else if (field.characterMaximumLength && !isCustomType) {
|
||||
const dataType = sortedDataTypeMap[databaseType].find(
|
||||
(v) => v.id === value
|
||||
);
|
||||
|
||||
if (dataType?.hasCharMaxLength) {
|
||||
characterMaximumLength = field.characterMaximumLength;
|
||||
}
|
||||
}
|
||||
|
||||
updateField({
|
||||
characterMaximumLength,
|
||||
type: dataTypeDataToDataType(
|
||||
dataType ?? {
|
||||
id: value as string,
|
||||
name: value as string,
|
||||
}
|
||||
),
|
||||
type: newType,
|
||||
});
|
||||
},
|
||||
[updateField, databaseType, field.characterMaximumLength]
|
||||
@@ -89,6 +137,21 @@ export const TableField: React.FC<TableFieldProps> = ({
|
||||
transition,
|
||||
};
|
||||
|
||||
// Helper to get the display value for custom types
|
||||
const getCustomTypeDisplayValue = () => {
|
||||
if (field.type.id.startsWith('custom-type-')) {
|
||||
const typeName = field.type.name;
|
||||
const customType = customTypes?.find((ct) => ct.name === typeName);
|
||||
|
||||
if (customType) {
|
||||
return typeName;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const customTypeDisplayValue = getCustomTypeDisplayValue();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-1 touch-none flex-row justify-between p-1"
|
||||
@@ -128,17 +191,29 @@ export const TableField: React.FC<TableFieldProps> = ({
|
||||
<span>
|
||||
<SelectBox
|
||||
className="flex h-8 min-h-8 w-full"
|
||||
popoverClassName="min-w-[350px]"
|
||||
options={dataFieldOptions}
|
||||
placeholder={t(
|
||||
'side_panel.tables_section.table.field_type'
|
||||
)}
|
||||
value={field.type.id}
|
||||
valueSuffix={
|
||||
field.characterMaximumLength
|
||||
? `(${field.characterMaximumLength})`
|
||||
: ''
|
||||
customTypeDisplayValue
|
||||
? ``
|
||||
: field.characterMaximumLength
|
||||
? `(${field.characterMaximumLength})`
|
||||
: ''
|
||||
}
|
||||
optionSuffix={(option) => {
|
||||
// For custom types, we don't need to add a suffix
|
||||
if (
|
||||
option.value
|
||||
.toString()
|
||||
.startsWith('custom-type-')
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const type = sortedDataTypeMap[
|
||||
databaseType
|
||||
].find((v) => v.id === option.value);
|
||||
@@ -161,10 +236,12 @@ export const TableField: React.FC<TableFieldProps> = ({
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{field.type.name}
|
||||
{field.characterMaximumLength
|
||||
? `(${field.characterMaximumLength})`
|
||||
: ''}
|
||||
{customTypeDisplayValue ||
|
||||
`${field.type.name}${
|
||||
field.characterMaximumLength
|
||||
? `(${field.characterMaximumLength})`
|
||||
: ''
|
||||
}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user