Compare commits

..

13 Commits

Author SHA1 Message Date
Guy Ben-Aharon
8cbcd41213 chore(main): release 1.18.0 2025-11-02 20:40:43 +02:00
Guy Ben-Aharon
4fd940afbb fix: notes colors (#970) 2025-11-02 20:40:08 +02:00
Guy Ben-Aharon
3d85bcc6ab fix: notes with readonly (#969) 2025-11-02 19:18:58 +02:00
Guy Ben-Aharon
973b7663b1 fix: dbml with notes (#968) 2025-11-02 18:07:53 +02:00
Guy Ben-Aharon
6d38ebe3ec feat: add sticky notes (#967)
* feat: add sticky notes

* feat: add sticky notes
2025-11-02 15:42:18 +02:00
Jonathan Fishner
68412f90a7 fix: add postgres array type support for import and export (#958)
* fix: add postgres array type support for import and export

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-10-27 17:02:44 +02:00
Guy Ben-Aharon
084a1d505c chore(main): release 1.17.0 (#936) 2025-10-27 16:27:46 +02:00
Guy Ben-Aharon
91e713c30a fix: import array fields (#961) 2025-10-27 11:55:11 +02:00
Guy Ben-Aharon
acf6d4b365 fix: show SQL Script option conditionally for databases without DDL support (#960) 2025-10-27 11:24:23 +02:00
Guy Ben-Aharon
9e8979d062 update translate (#957) 2025-10-21 15:44:49 +03:00
Guy Ben-Aharon
9ed27cf30c fix: preserve multi-word types in DBML export/import (#956)
* fix: preserve multi-word types in DBML export/import

* fix
2025-10-21 15:10:02 +03:00
Guy Ben-Aharon
2c4b344efb fix: resolve dbml increment & nullable attributes issue (#954)
* fix: resolve dbml increment attribute

* fix nullable

* fix
2025-10-21 12:31:32 +03:00
Guy Ben-Aharon
ccb29e0a57 fix: resolve canvas filter tree state issues (#953) 2025-10-20 17:12:15 +03:00
85 changed files with 3911 additions and 215 deletions

View File

@@ -1,6 +1,21 @@
# Changelog
## [1.17.0](https://github.com/chartdb/chartdb/compare/v1.16.0...v1.17.0) (2025-10-20)
## [1.18.0](https://github.com/chartdb/chartdb/compare/v1.17.0...v1.18.0) (2025-11-02)
### Features
* add sticky notes ([#967](https://github.com/chartdb/chartdb/issues/967)) ([6d38ebe](https://github.com/chartdb/chartdb/commit/6d38ebe3ecd271b80c33db7e2731594a39b004d5))
### Bug Fixes
* add postgres array type support for import and export ([#958](https://github.com/chartdb/chartdb/issues/958)) ([68412f9](https://github.com/chartdb/chartdb/commit/68412f90a7d4466946b5f20b1b31ae64708d2031))
* dbml with notes ([#968](https://github.com/chartdb/chartdb/issues/968)) ([973b766](https://github.com/chartdb/chartdb/commit/973b7663b14fd0ba97e3358db9c6621663dec62c))
* notes colors ([#970](https://github.com/chartdb/chartdb/issues/970)) ([4fd940a](https://github.com/chartdb/chartdb/commit/4fd940afbb33cb3306566e73c5640c6305c08a72))
* notes with readonly ([#969](https://github.com/chartdb/chartdb/issues/969)) ([3d85bcc](https://github.com/chartdb/chartdb/commit/3d85bcc6ab862cc0a2ef6b29ae905afde88b9821))
## [1.17.0](https://github.com/chartdb/chartdb/compare/v1.16.0...v1.17.0) (2025-10-27)
### Features
@@ -19,8 +34,13 @@
* auto-enter edit mode when creating new tables from canvas ([#943](https://github.com/chartdb/chartdb/issues/943)) ([bcd8aa9](https://github.com/chartdb/chartdb/commit/bcd8aa9378aa563f40a2b6802cc503be4c882356))
* dbml diff fields types preview ([#934](https://github.com/chartdb/chartdb/issues/934)) ([bb03309](https://github.com/chartdb/chartdb/commit/bb033091b1f64b888822be1423a80f16f5314f6b))
* exit table edit on area click ([#945](https://github.com/chartdb/chartdb/issues/945)) ([38fedce](https://github.com/chartdb/chartdb/commit/38fedcec0c10ea2b3f0b7fc92ca1f5ac9e540389))
* import array fields ([#961](https://github.com/chartdb/chartdb/issues/961)) ([91e713c](https://github.com/chartdb/chartdb/commit/91e713c30a44f1ba7a767ca7816079610136fcb8))
* manipulate schema directly from the canvas ([#947](https://github.com/chartdb/chartdb/issues/947)) ([7ad0e77](https://github.com/chartdb/chartdb/commit/7ad0e7712de975a23b2a337dc0a4a7fb4b122bd1))
* preserve multi-word types in DBML export/import ([#956](https://github.com/chartdb/chartdb/issues/956)) ([9ed27cf](https://github.com/chartdb/chartdb/commit/9ed27cf30cca1312713e80e525138f0c27154936))
* prevent text input glitch when editing table field names ([#944](https://github.com/chartdb/chartdb/issues/944)) ([498655e](https://github.com/chartdb/chartdb/commit/498655e7b77e57eaf641ba86263ce1ef60b93e16))
* resolve canvas filter tree state issues ([#953](https://github.com/chartdb/chartdb/issues/953)) ([ccb29e0](https://github.com/chartdb/chartdb/commit/ccb29e0a574dfa4cfdf0ebf242a4c4aaa48cc37b))
* resolve dbml increment & nullable attributes issue ([#954](https://github.com/chartdb/chartdb/issues/954)) ([2c4b344](https://github.com/chartdb/chartdb/commit/2c4b344efb24041e7f607fc6124e109b69aaa457))
* show SQL Script option conditionally for databases without DDL support ([#960](https://github.com/chartdb/chartdb/issues/960)) ([acf6d4b](https://github.com/chartdb/chartdb/commit/acf6d4b3654d8868b8a8ebf717c608d9749b71da))
* use flag for custom types ([#951](https://github.com/chartdb/chartdb/issues/951)) ([62dec48](https://github.com/chartdb/chartdb/commit/62dec4857211b705a8039691da1772263ea986fe))
## [1.16.0](https://github.com/chartdb/chartdb/compare/v1.15.1...v1.16.0) (2025-09-24)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "chartdb",
"version": "1.17.0",
"version": "1.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "chartdb",
"version": "1.17.0",
"version": "1.18.0",
"dependencies": {
"@ai-sdk/openai": "^0.0.51",
"@dbml/core": "^3.13.9",

View File

@@ -1,7 +1,7 @@
{
"name": "chartdb",
"private": true,
"version": "1.17.0",
"version": "1.18.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,9 +1,16 @@
import React, { forwardRef } from 'react';
import EmptyStateImage from '@/assets/empty_state.png';
import EmptyStateImageDark from '@/assets/empty_state_dark.png';
import { Label } from '@/components/label/label';
import { cn } from '@/lib/utils';
import { useTheme } from '@/hooks/use-theme';
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '../empty/empty';
export interface EmptyStateProps {
title: string;
@@ -38,26 +45,29 @@ export const EmptyState = forwardRef<
className
)}
>
<img
src={
effectiveTheme === 'dark'
? EmptyStateImageDark
: EmptyStateImage
}
alt="Empty state"
className={cn('mb-2 w-20', imageClassName)}
/>
<Label className={cn('text-base', titleClassName)}>
{title}
</Label>
<Label
className={cn(
'text-sm text-center font-normal text-muted-foreground',
descriptionClassName
)}
>
{description}
</Label>
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
{/* <Group /> */}
<img
src={
effectiveTheme === 'dark'
? EmptyStateImageDark
: EmptyStateImage
}
alt="Empty state"
className={cn('p-2', imageClassName)}
/>
</EmptyMedia>
<EmptyTitle className={titleClassName}>
{title}
</EmptyTitle>
<EmptyDescription className={descriptionClassName}>
{description}
</EmptyDescription>
</EmptyHeader>
<EmptyContent />
</Empty>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils/index';
function Empty({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty"
className={cn(
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12',
className
)}
{...props}
/>
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-header"
className={cn(
'flex max-w-sm flex-col items-center gap-2 text-center',
className
)}
{...props}
/>
);
}
const emptyMediaVariants = cva(
'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: 'default',
},
}
);
function EmptyMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-title"
className={cn('text-lg font-medium tracking-tight', className)}
{...props}
/>
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<div
data-slot="empty-description"
className={cn(
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
className
)}
{...props}
/>
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-content"
className={cn(
'flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm',
className
)}
{...props}
/>
);
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
};

View File

@@ -42,6 +42,7 @@ interface TreeViewProps<
renderHoverComponent?: (node: TreeNode<Type, Context>) => ReactNode;
renderActionsComponent?: (node: TreeNode<Type, Context>) => ReactNode;
loadingNodeIds?: string[];
disableCache?: boolean;
}
export function TreeView<
@@ -62,12 +63,14 @@ export function TreeView<
renderHoverComponent,
renderActionsComponent,
loadingNodeIds,
disableCache = false,
}: TreeViewProps<Type, Context>) {
const { expanded, loading, loadedChildren, hasMoreChildren, toggleNode } =
useTree({
fetchChildren,
expanded: expandedProp,
setExpanded: setExpandedProp,
disableCache,
});
const [selectedIdInternal, setSelectedIdInternal] = React.useState<
string | undefined
@@ -145,6 +148,7 @@ export function TreeView<
renderHoverComponent={renderHoverComponent}
renderActionsComponent={renderActionsComponent}
loadingNodeIds={loadingNodeIds}
disableCache={disableCache}
/>
))}
</div>
@@ -179,6 +183,7 @@ interface TreeNodeProps<
renderHoverComponent?: (node: TreeNode<Type, Context>) => ReactNode;
renderActionsComponent?: (node: TreeNode<Type, Context>) => ReactNode;
loadingNodeIds?: string[];
disableCache?: boolean;
}
function TreeNode<Type extends string, Context extends Record<Type, unknown>>({
@@ -201,11 +206,16 @@ function TreeNode<Type extends string, Context extends Record<Type, unknown>>({
renderHoverComponent,
renderActionsComponent,
loadingNodeIds,
disableCache = false,
}: TreeNodeProps<Type, Context>) {
const [isHovered, setIsHovered] = useState(false);
const isExpanded = expanded[node.id];
const isLoading = loading[node.id];
const children = loadedChildren[node.id] || node.children;
// If cache is disabled, always use fresh node.children
// Otherwise, use cached loadedChildren if available (for async fetched data)
const children = disableCache
? node.children
: node.children || loadedChildren[node.id];
const isSelected = selectedId === node.id;
const IconComponent =
@@ -423,6 +433,7 @@ function TreeNode<Type extends string, Context extends Record<Type, unknown>>({
renderHoverComponent={renderHoverComponent}
renderActionsComponent={renderActionsComponent}
loadingNodeIds={loadingNodeIds}
disableCache={disableCache}
/>
))}
{isLoading ? (

View File

@@ -28,10 +28,12 @@ export function useTree<
fetchChildren,
expanded: expandedProp,
setExpanded: setExpandedProp,
disableCache = false,
}: {
fetchChildren?: FetchChildrenFunction<Type, Context>;
expanded?: ExpandedState;
setExpanded?: Dispatch<SetStateAction<ExpandedState>>;
disableCache?: boolean;
}) {
const [expandedInternal, setExpandedInternal] = useState<ExpandedState>({});
@@ -89,8 +91,8 @@ export function useTree<
// Get any previously fetched children
const previouslyFetchedChildren = loadedChildren[nodeId] || [];
// If we have static children, merge them with any previously fetched children
if (staticChildren?.length) {
// Only cache if caching is enabled
if (!disableCache && staticChildren?.length) {
const mergedChildren = mergeChildren(
staticChildren,
previouslyFetchedChildren
@@ -110,8 +112,8 @@ export function useTree<
// Set expanded state immediately to show static/previously fetched children
setExpanded((prev) => ({ ...prev, [nodeId]: true }));
// If we haven't loaded dynamic children yet
if (!previouslyFetchedChildren.length) {
// If we haven't loaded dynamic children yet and cache is enabled
if (!disableCache && !previouslyFetchedChildren.length) {
setLoading((prev) => ({ ...prev, [nodeId]: true }));
try {
const fetchedChildren = await fetchChildren?.(
@@ -140,7 +142,14 @@ export function useTree<
}
}
},
[expanded, loadedChildren, fetchChildren, mergeChildren, setExpanded]
[
expanded,
loadedChildren,
fetchChildren,
mergeChildren,
setExpanded,
disableCache,
]
);
return {

View File

@@ -2,6 +2,24 @@ import { createContext } from 'react';
import { emptyFn } from '@/lib/utils';
import type { Graph } from '@/lib/graph';
import { createGraph } from '@/lib/graph';
import { EventEmitter } from 'ahooks/lib/useEventEmitter';
export type CanvasEventType = 'pan_click';
export type CanvasEventBase<T extends CanvasEventType, D> = {
action: T;
data: D;
};
export type PanClickEvent = CanvasEventBase<
'pan_click',
{
x: number;
y: number;
}
>;
export type CanvasEvent = PanClickEvent;
export interface CanvasContext {
reorderTables: (options?: { updateHistory?: boolean }) => void;
@@ -49,6 +67,7 @@ export interface CanvasContext {
y: number;
}) => void;
hideCreateRelationshipNode: () => void;
events: EventEmitter<CanvasEvent>;
}
export const canvasContext = createContext<CanvasContext>({
@@ -68,4 +87,5 @@ export const canvasContext = createContext<CanvasContext>({
setHoveringTableId: emptyFn,
showCreateRelationshipNode: emptyFn,
hideCreateRelationshipNode: emptyFn,
events: new EventEmitter(),
});

View File

@@ -5,7 +5,7 @@ import React, {
useEffect,
useRef,
} from 'react';
import type { CanvasContext } from './canvas-context';
import type { CanvasContext, CanvasEvent } from './canvas-context';
import { canvasContext } from './canvas-context';
import { useChartDB } from '@/hooks/use-chartdb';
import { adjustTablePositions } from '@/lib/domain/db-table';
@@ -20,6 +20,7 @@ import {
CREATE_RELATIONSHIP_NODE_ID,
type CreateRelationshipNodeType,
} from '@/pages/editor-page/canvas/create-relationship-node/create-relationship-node';
import { useEventEmitter } from 'ahooks';
interface CanvasProviderProps {
children: ReactNode;
@@ -43,6 +44,8 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
fieldId?: string;
} | null>(null);
const events = useEventEmitter<CanvasEvent>();
const [showFilter, setShowFilter] = useState(false);
const [tempFloatingEdge, setTempFloatingEdge] =
@@ -212,6 +215,7 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
setHoveringTableId,
showCreateRelationshipNode,
hideCreateRelationshipNode,
events,
}}
>
{children}

View File

@@ -12,6 +12,7 @@ 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';
import type { Note } from '@/lib/domain/note';
export type ChartDBEventType =
| 'add_tables'
@@ -74,6 +75,7 @@ export interface ChartDBContext {
dependencies: DBDependency[];
areas: Area[];
customTypes: DBCustomType[];
notes: Note[];
currentDiagram: Diagram;
events: EventEmitter<ChartDBEvent>;
readonly?: boolean;
@@ -255,6 +257,31 @@ export interface ChartDBContext {
options?: { updateHistory: boolean }
) => Promise<void>;
// Note operations
createNote: (attributes?: Partial<Omit<Note, 'id'>>) => Promise<Note>;
addNote: (
note: Note,
options?: { updateHistory: boolean }
) => Promise<void>;
addNotes: (
notes: Note[],
options?: { updateHistory: boolean }
) => Promise<void>;
getNote: (id: string) => Note | null;
removeNote: (
id: string,
options?: { updateHistory: boolean }
) => Promise<void>;
removeNotes: (
ids: string[],
options?: { updateHistory: boolean }
) => Promise<void>;
updateNote: (
id: string,
note: Partial<Note>,
options?: { updateHistory: boolean }
) => Promise<void>;
// Custom type operations
createCustomType: (
attributes?: Partial<Omit<DBCustomType, 'id'>>
@@ -292,6 +319,7 @@ export const chartDBContext = createContext<ChartDBContext>({
dependencies: [],
areas: [],
customTypes: [],
notes: [],
schemas: [],
highlightCustomTypeId: emptyFn,
currentDiagram: {
@@ -368,6 +396,15 @@ export const chartDBContext = createContext<ChartDBContext>({
removeAreas: emptyFn,
updateArea: emptyFn,
// Note operations
createNote: emptyFn,
addNote: emptyFn,
addNotes: emptyFn,
getNote: emptyFn,
removeNote: emptyFn,
removeNotes: emptyFn,
updateNote: emptyFn,
// Custom type operations
createCustomType: emptyFn,
addCustomType: emptyFn,

View File

@@ -24,6 +24,7 @@ import { defaultSchemas } from '@/lib/data/default-schemas';
import { useEventEmitter } from 'ahooks';
import type { DBDependency } from '@/lib/domain/db-dependency';
import type { Area } from '@/lib/domain/area';
import type { Note } from '@/lib/domain/note';
import { storageInitialValue } from '../storage-context/storage-context';
import { useDiff } from '../diff-context/use-diff';
import type { DiffCalculatedEvent } from '../diff-context/diff-context';
@@ -67,6 +68,7 @@ export const ChartDBProvider: React.FC<
const [customTypes, setCustomTypes] = useState<DBCustomType[]>(
diagram?.customTypes ?? []
);
const [notes, setNotes] = useState<Note[]>(diagram?.notes ?? []);
const { events: diffEvents } = useDiff();
@@ -147,6 +149,7 @@ export const ChartDBProvider: React.FC<
dependencies,
areas,
customTypes,
notes,
}),
[
diagramId,
@@ -158,6 +161,7 @@ export const ChartDBProvider: React.FC<
dependencies,
areas,
customTypes,
notes,
diagramCreatedAt,
diagramUpdatedAt,
]
@@ -171,6 +175,7 @@ export const ChartDBProvider: React.FC<
setDependencies([]);
setAreas([]);
setCustomTypes([]);
setNotes([]);
setDiagramUpdatedAt(updatedAt);
resetRedoStack();
@@ -183,6 +188,7 @@ export const ChartDBProvider: React.FC<
db.deleteDiagramDependencies(diagramId),
db.deleteDiagramAreas(diagramId),
db.deleteDiagramCustomTypes(diagramId),
db.deleteDiagramNotes(diagramId),
]);
}, [db, diagramId, resetRedoStack, resetUndoStack]);
@@ -197,6 +203,7 @@ export const ChartDBProvider: React.FC<
setDependencies([]);
setAreas([]);
setCustomTypes([]);
setNotes([]);
resetRedoStack();
resetUndoStack();
@@ -207,6 +214,7 @@ export const ChartDBProvider: React.FC<
db.deleteDiagramDependencies(diagramId),
db.deleteDiagramAreas(diagramId),
db.deleteDiagramCustomTypes(diagramId),
db.deleteDiagramNotes(diagramId),
]);
}, [db, diagramId, resetRedoStack, resetUndoStack]);
@@ -1528,6 +1536,130 @@ export const ChartDBProvider: React.FC<
[db, diagramId, setAreas, getArea, addUndoAction, resetRedoStack]
);
// Note operations
const addNotes: ChartDBContext['addNotes'] = useCallback(
async (notes: Note[], options = { updateHistory: true }) => {
setNotes((currentNotes) => [...currentNotes, ...notes]);
const updatedAt = new Date();
setDiagramUpdatedAt(updatedAt);
await Promise.all([
...notes.map((note) => db.addNote({ diagramId, note })),
db.updateDiagram({ id: diagramId, attributes: { updatedAt } }),
]);
if (options.updateHistory) {
addUndoAction({
action: 'addNotes',
redoData: { notes },
undoData: { noteIds: notes.map((n) => n.id) },
});
resetRedoStack();
}
},
[db, diagramId, setNotes, addUndoAction, resetRedoStack]
);
const addNote: ChartDBContext['addNote'] = useCallback(
async (note: Note, options = { updateHistory: true }) => {
return addNotes([note], options);
},
[addNotes]
);
const createNote: ChartDBContext['createNote'] = useCallback(
async (attributes) => {
const note: Note = {
id: generateId(),
content: '',
x: 0,
y: 0,
width: 200,
height: 150,
color: '#ffe374', // Default warm yellow
...attributes,
};
await addNote(note);
return note;
},
[addNote]
);
const getNote: ChartDBContext['getNote'] = useCallback(
(id: string) => notes.find((note) => note.id === id) ?? null,
[notes]
);
const removeNotes: ChartDBContext['removeNotes'] = useCallback(
async (ids: string[], options = { updateHistory: true }) => {
const prevNotes = [
...notes.filter((note) => ids.includes(note.id)),
];
setNotes((notes) => notes.filter((note) => !ids.includes(note.id)));
const updatedAt = new Date();
setDiagramUpdatedAt(updatedAt);
await Promise.all([
...ids.map((id) => db.deleteNote({ diagramId, id })),
db.updateDiagram({ id: diagramId, attributes: { updatedAt } }),
]);
if (prevNotes.length > 0 && options.updateHistory) {
addUndoAction({
action: 'removeNotes',
redoData: { noteIds: ids },
undoData: { notes: prevNotes },
});
resetRedoStack();
}
},
[db, diagramId, setNotes, notes, addUndoAction, resetRedoStack]
);
const removeNote: ChartDBContext['removeNote'] = useCallback(
async (id: string, options = { updateHistory: true }) => {
return removeNotes([id], options);
},
[removeNotes]
);
const updateNote: ChartDBContext['updateNote'] = useCallback(
async (
id: string,
note: Partial<Note>,
options = { updateHistory: true }
) => {
const prevNote = getNote(id);
setNotes((notes) =>
notes.map((n) => (n.id === id ? { ...n, ...note } : n))
);
const updatedAt = new Date();
setDiagramUpdatedAt(updatedAt);
await Promise.all([
db.updateDiagram({ id: diagramId, attributes: { updatedAt } }),
db.updateNote({ id, attributes: note }),
]);
if (!!prevNote && options.updateHistory) {
addUndoAction({
action: 'updateNote',
redoData: { noteId: id, note },
undoData: { noteId: id, note: prevNote },
});
resetRedoStack();
}
},
[db, diagramId, setNotes, getNote, addUndoAction, resetRedoStack]
);
const highlightCustomTypeId = useCallback(
(id?: string) => setHighlightedCustomTypeId(id),
[setHighlightedCustomTypeId]
@@ -1554,6 +1686,7 @@ export const ChartDBProvider: React.FC<
setDiagramCreatedAt(diagram.createdAt);
setDiagramUpdatedAt(diagram.updatedAt);
setHighlightedCustomTypeId(undefined);
setNotes(diagram.notes ?? []);
events.emit({ action: 'load_diagram', data: { diagram } });
@@ -1574,6 +1707,7 @@ export const ChartDBProvider: React.FC<
setDiagramUpdatedAt,
setHighlightedCustomTypeId,
events,
setNotes,
resetRedoStack,
resetUndoStack,
]
@@ -1597,6 +1731,7 @@ export const ChartDBProvider: React.FC<
includeDependencies: true,
includeAreas: true,
includeCustomTypes: true,
includeNotes: true,
});
if (diagram) {
@@ -1762,6 +1897,7 @@ export const ChartDBProvider: React.FC<
relationships,
dependencies,
areas,
notes,
currentDiagram,
schemas,
events,
@@ -1825,6 +1961,13 @@ export const ChartDBProvider: React.FC<
updateCustomType,
highlightCustomTypeId,
highlightedCustomType,
createNote,
addNote,
addNotes,
getNote,
removeNote,
removeNotes,
updateNote,
}}
>
{children}

View File

@@ -39,6 +39,9 @@ export const HistoryProvider: React.FC<React.PropsWithChildren> = ({
addCustomTypes,
removeCustomTypes,
updateCustomType,
addNotes,
removeNotes,
updateNote,
} = useChartDB();
const redoActionHandlers = useMemo(
@@ -135,6 +138,15 @@ export const HistoryProvider: React.FC<React.PropsWithChildren> = ({
updateHistory: false,
});
},
addNotes: ({ redoData: { notes } }) => {
return addNotes(notes, { updateHistory: false });
},
removeNotes: ({ redoData: { noteIds } }) => {
return removeNotes(noteIds, { updateHistory: false });
},
updateNote: ({ redoData: { noteId, note } }) => {
return updateNote(noteId, note, { updateHistory: false });
},
}),
[
addTables,
@@ -160,6 +172,9 @@ export const HistoryProvider: React.FC<React.PropsWithChildren> = ({
addCustomTypes,
removeCustomTypes,
updateCustomType,
addNotes,
removeNotes,
updateNote,
]
);
@@ -271,6 +286,15 @@ export const HistoryProvider: React.FC<React.PropsWithChildren> = ({
updateHistory: false,
});
},
addNotes: ({ undoData: { noteIds } }) => {
return removeNotes(noteIds, { updateHistory: false });
},
removeNotes: ({ undoData: { notes } }) => {
return addNotes(notes, { updateHistory: false });
},
updateNote: ({ undoData: { noteId, note } }) => {
return updateNote(noteId, note, { updateHistory: false });
},
}),
[
addTables,
@@ -296,6 +320,9 @@ export const HistoryProvider: React.FC<React.PropsWithChildren> = ({
addCustomTypes,
removeCustomTypes,
updateCustomType,
addNotes,
removeNotes,
updateNote,
]
);

View File

@@ -6,6 +6,7 @@ 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';
import type { Note } from '@/lib/domain/note';
type Action = keyof ChartDBContext;
@@ -161,6 +162,24 @@ type RedoUndoActionRemoveCustomTypes = RedoUndoActionBase<
{ customTypes: DBCustomType[] }
>;
type RedoUndoActionAddNotes = RedoUndoActionBase<
'addNotes',
{ notes: Note[] },
{ noteIds: string[] }
>;
type RedoUndoActionUpdateNote = RedoUndoActionBase<
'updateNote',
{ noteId: string; note: Partial<Note> },
{ noteId: string; note: Partial<Note> }
>;
type RedoUndoActionRemoveNotes = RedoUndoActionBase<
'removeNotes',
{ noteIds: string[] },
{ notes: Note[] }
>;
export type RedoUndoAction =
| RedoUndoActionAddTables
| RedoUndoActionRemoveTables
@@ -184,7 +203,10 @@ export type RedoUndoAction =
| RedoUndoActionRemoveAreas
| RedoUndoActionAddCustomTypes
| RedoUndoActionUpdateCustomType
| RedoUndoActionRemoveCustomTypes;
| RedoUndoActionRemoveCustomTypes
| RedoUndoActionAddNotes
| RedoUndoActionUpdateNote
| RedoUndoActionRemoveNotes;
export type RedoActionData<T extends Action> = Extract<
RedoUndoAction,

View File

@@ -5,8 +5,10 @@ export type SidebarSection =
| 'dbml'
| 'tables'
| 'refs'
| 'areas'
| 'customTypes';
| 'customTypes'
| 'visuals';
export type VisualsTab = 'areas' | 'notes';
export interface LayoutContext {
openedTableInSidebar: string | undefined;
@@ -27,6 +29,10 @@ export interface LayoutContext {
openAreaFromSidebar: (areaId: string) => void;
closeAllAreasInSidebar: () => void;
openedNoteInSidebar: string | undefined;
openNoteFromSidebar: (noteId: string) => void;
closeAllNotesInSidebar: () => void;
openedCustomTypeInSidebar: string | undefined;
openCustomTypeFromSidebar: (customTypeId: string) => void;
closeAllCustomTypesInSidebar: () => void;
@@ -34,6 +40,9 @@ export interface LayoutContext {
selectedSidebarSection: SidebarSection;
selectSidebarSection: (section: SidebarSection) => void;
selectedVisualsTab: VisualsTab;
selectVisualsTab: (tab: VisualsTab) => void;
isSidePanelShowed: boolean;
hideSidePanel: () => void;
showSidePanel: () => void;
@@ -58,6 +67,10 @@ export const layoutContext = createContext<LayoutContext>({
openAreaFromSidebar: emptyFn,
closeAllAreasInSidebar: emptyFn,
openedNoteInSidebar: undefined,
openNoteFromSidebar: emptyFn,
closeAllNotesInSidebar: emptyFn,
openedCustomTypeInSidebar: undefined,
openCustomTypeFromSidebar: emptyFn,
closeAllCustomTypesInSidebar: emptyFn,
@@ -66,6 +79,9 @@ export const layoutContext = createContext<LayoutContext>({
openTableFromSidebar: emptyFn,
closeAllTablesInSidebar: emptyFn,
selectedVisualsTab: 'areas',
selectVisualsTab: emptyFn,
isSidePanelShowed: false,
hideSidePanel: emptyFn,
showSidePanel: emptyFn,

View File

@@ -1,5 +1,9 @@
import React from 'react';
import type { LayoutContext, SidebarSection } from './layout-context';
import type {
LayoutContext,
SidebarSection,
VisualsTab,
} from './layout-context';
import { layoutContext } from './layout-context';
import { useBreakpoint } from '@/hooks/use-breakpoint';
@@ -16,10 +20,15 @@ export const LayoutProvider: React.FC<React.PropsWithChildren> = ({
const [openedAreaInSidebar, setOpenedAreaInSidebar] = React.useState<
string | undefined
>();
const [openedNoteInSidebar, setOpenedNoteInSidebar] = React.useState<
string | undefined
>();
const [openedCustomTypeInSidebar, setOpenedCustomTypeInSidebar] =
React.useState<string | undefined>();
const [selectedSidebarSection, setSelectedSidebarSection] =
React.useState<SidebarSection>('tables');
const [selectedVisualsTab, setSelectedVisualsTab] =
React.useState<VisualsTab>('areas');
const [isSidePanelShowed, setIsSidePanelShowed] =
React.useState<boolean>(isDesktop);
@@ -38,6 +47,9 @@ export const LayoutProvider: React.FC<React.PropsWithChildren> = ({
const closeAllAreasInSidebar: LayoutContext['closeAllAreasInSidebar'] =
() => setOpenedAreaInSidebar('');
const closeAllNotesInSidebar: LayoutContext['closeAllNotesInSidebar'] =
() => setOpenedNoteInSidebar('');
const closeAllCustomTypesInSidebar: LayoutContext['closeAllCustomTypesInSidebar'] =
() => setOpenedCustomTypeInSidebar('');
@@ -83,10 +95,20 @@ export const LayoutProvider: React.FC<React.PropsWithChildren> = ({
areaId
) => {
showSidePanel();
setSelectedSidebarSection('areas');
setSelectedSidebarSection('visuals');
setSelectedVisualsTab('areas');
setOpenedAreaInSidebar(areaId);
};
const openNoteFromSidebar: LayoutContext['openNoteFromSidebar'] = (
noteId
) => {
showSidePanel();
setSelectedSidebarSection('visuals');
setSelectedVisualsTab('notes');
setOpenedNoteInSidebar(noteId);
};
const openCustomTypeFromSidebar: LayoutContext['openCustomTypeFromSidebar'] =
(customTypeId) => {
showSidePanel();
@@ -116,9 +138,14 @@ export const LayoutProvider: React.FC<React.PropsWithChildren> = ({
openedAreaInSidebar,
openAreaFromSidebar,
closeAllAreasInSidebar,
openedNoteInSidebar,
openNoteFromSidebar,
closeAllNotesInSidebar,
openedCustomTypeInSidebar,
openCustomTypeFromSidebar,
closeAllCustomTypesInSidebar,
selectedVisualsTab,
selectVisualsTab: setSelectedVisualsTab,
}}
>
{children}

View File

@@ -8,6 +8,7 @@ import type { DBDependency } from '@/lib/domain/db-dependency';
import type { Area } from '@/lib/domain/area';
import type { DBCustomType } from '@/lib/domain/db-custom-type';
import type { DiagramFilter } from '@/lib/domain/diagram-filter/diagram-filter';
import type { Note } from '@/lib/domain/note';
export interface StorageContext {
// Config operations
@@ -30,6 +31,7 @@ export interface StorageContext {
includeDependencies?: boolean;
includeAreas?: boolean;
includeCustomTypes?: boolean;
includeNotes?: boolean;
}) => Promise<Diagram[]>;
getDiagram: (
id: string,
@@ -39,6 +41,7 @@ export interface StorageContext {
includeDependencies?: boolean;
includeAreas?: boolean;
includeCustomTypes?: boolean;
includeNotes?: boolean;
}
) => Promise<Diagram | undefined>;
updateDiagram: (params: {
@@ -135,6 +138,20 @@ export interface StorageContext {
}) => Promise<void>;
listCustomTypes: (diagramId: string) => Promise<DBCustomType[]>;
deleteDiagramCustomTypes: (diagramId: string) => Promise<void>;
// Note operations
addNote: (params: { diagramId: string; note: Note }) => Promise<void>;
getNote: (params: {
diagramId: string;
id: string;
}) => Promise<Note | undefined>;
updateNote: (params: {
id: string;
attributes: Partial<Note>;
}) => Promise<void>;
deleteNote: (params: { diagramId: string; id: string }) => Promise<void>;
listNotes: (diagramId: string) => Promise<Note[]>;
deleteDiagramNotes: (diagramId: string) => Promise<void>;
}
export const storageInitialValue: StorageContext = {
@@ -187,6 +204,14 @@ export const storageInitialValue: StorageContext = {
deleteCustomType: emptyFn,
listCustomTypes: emptyFn,
deleteDiagramCustomTypes: emptyFn,
// Note operations
addNote: emptyFn,
getNote: emptyFn,
updateNote: emptyFn,
deleteNote: emptyFn,
listNotes: emptyFn,
deleteDiagramNotes: emptyFn,
};
export const storageContext =

View File

@@ -11,6 +11,7 @@ import type { DBDependency } from '@/lib/domain/db-dependency';
import type { Area } from '@/lib/domain/area';
import type { DBCustomType } from '@/lib/domain/db-custom-type';
import type { DiagramFilter } from '@/lib/domain/diagram-filter/diagram-filter';
import type { Note } from '@/lib/domain/note';
export const StorageProvider: React.FC<React.PropsWithChildren> = ({
children,
@@ -41,6 +42,10 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
DBCustomType & { diagramId: string },
'id' // primary key "id" (for the typings only)
>;
notes: EntityTable<
Note & { diagramId: string },
'id' // primary key "id" (for the typings only)
>;
config: EntityTable<
ChartDBConfig & { id: number },
'id' // primary key "id" (for the typings only)
@@ -216,6 +221,23 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
tx.table('config').clear();
});
dexieDB.version(13).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',
diagram_filters: 'diagramId, tableIds, schemasIds',
notes: '++id, diagramId, content, x, y, width, height, color',
});
dexieDB.on('ready', async () => {
const config = await dexieDB.config.get(1);
@@ -550,6 +572,56 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
[db]
);
// Note operations
const addNote: StorageContext['addNote'] = useCallback(
async ({ note, diagramId }) => {
await db.notes.add({
...note,
diagramId,
});
},
[db]
);
const getNote: StorageContext['getNote'] = useCallback(
async ({ diagramId, id }) => {
return await db.notes.get({ id, diagramId });
},
[db]
);
const updateNote: StorageContext['updateNote'] = useCallback(
async ({ id, attributes }) => {
await db.notes.update(id, attributes);
},
[db]
);
const deleteNote: StorageContext['deleteNote'] = useCallback(
async ({ diagramId, id }) => {
await db.notes.where({ id, diagramId }).delete();
},
[db]
);
const listNotes: StorageContext['listNotes'] = useCallback(
async (diagramId) => {
return await db.notes
.where('diagramId')
.equals(diagramId)
.toArray();
},
[db]
);
const deleteDiagramNotes: StorageContext['deleteDiagramNotes'] =
useCallback(
async (diagramId) => {
await db.notes.where('diagramId').equals(diagramId).delete();
},
[db]
);
const addDiagram: StorageContext['addDiagram'] = useCallback(
async ({ diagram }) => {
const promises = [];
@@ -597,9 +669,22 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
)
);
const notes = diagram.notes ?? [];
promises.push(
...notes.map((note) => addNote({ diagramId: diagram.id, note }))
);
await Promise.all(promises);
},
[db, addArea, addCustomType, addDependency, addRelationship, addTable]
[
db,
addArea,
addCustomType,
addDependency,
addRelationship,
addTable,
addNote,
]
);
const listDiagrams: StorageContext['listDiagrams'] = useCallback(
@@ -610,6 +695,7 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
includeDependencies: false,
includeAreas: false,
includeCustomTypes: false,
includeNotes: false,
}
): Promise<Diagram[]> => {
let diagrams = await db.diagrams.toArray();
@@ -663,6 +749,15 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
);
}
if (options.includeNotes) {
diagrams = await Promise.all(
diagrams.map(async (diagram) => {
diagram.notes = await listNotes(diagram.id);
return diagram;
})
);
}
return diagrams;
},
[
@@ -672,6 +767,7 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
listDependencies,
listRelationships,
listTables,
listNotes,
]
);
@@ -684,6 +780,7 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
includeDependencies: false,
includeAreas: false,
includeCustomTypes: false,
includeNotes: false,
}
): Promise<Diagram | undefined> => {
const diagram = await db.diagrams.get(id);
@@ -712,6 +809,10 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
diagram.customTypes = await listCustomTypes(id);
}
if (options.includeNotes) {
diagram.notes = await listNotes(id);
}
return diagram;
},
[
@@ -721,6 +822,7 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
listDependencies,
listRelationships,
listTables,
listNotes,
]
);
@@ -749,6 +851,9 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
.where('diagramId')
.equals(id)
.modify({ diagramId: attributes.id }),
db.notes.where('diagramId').equals(id).modify({
diagramId: attributes.id,
}),
]);
}
},
@@ -764,6 +869,7 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
db.db_dependencies.where('diagramId').equals(id).delete(),
db.areas.where('diagramId').equals(id).delete(),
db.db_custom_types.where('diagramId').equals(id).delete(),
db.notes.where('diagramId').equals(id).delete(),
]);
},
[db]
@@ -810,6 +916,12 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
deleteCustomType,
listCustomTypes,
deleteDiagramCustomTypes,
addNote,
getNote,
updateNote,
deleteNote,
listNotes,
deleteDiagramNotes,
getDiagramFilter,
updateDiagramFilter,
deleteDiagramFilter,

View File

@@ -117,35 +117,37 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
</div>
) : null}
{DatabasesWithoutDDLInstructions.includes(databaseType) ? null : (
<div className="flex flex-col gap-1">
<p className="text-sm leading-6 text-primary">
How would you like to import?
</p>
<ToggleGroup
type="single"
className="ml-1 flex-wrap justify-start gap-2"
value={importMethod}
onValueChange={(value) => {
let selectedImportMethod: ImportMethod = 'query';
if (value) {
selectedImportMethod = value as ImportMethod;
}
<div className="flex flex-col gap-1">
<p className="text-sm leading-6 text-primary">
How would you like to import?
</p>
<ToggleGroup
type="single"
className="ml-1 flex-wrap justify-start gap-2"
value={importMethod}
onValueChange={(value) => {
let selectedImportMethod: ImportMethod = 'query';
if (value) {
selectedImportMethod = value as ImportMethod;
}
setImportMethod(selectedImportMethod);
}}
setImportMethod(selectedImportMethod);
}}
>
<ToggleGroupItem
value="query"
variant="outline"
className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700"
>
<ToggleGroupItem
value="query"
variant="outline"
className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700"
>
<Avatar className="h-3 w-4 rounded-none">
<AvatarImage src={logo} alt="query" />
<AvatarFallback>Query</AvatarFallback>
</Avatar>
Smart Query
</ToggleGroupItem>
<Avatar className="h-3 w-4 rounded-none">
<AvatarImage src={logo} alt="query" />
<AvatarFallback>Query</AvatarFallback>
</Avatar>
Smart Query
</ToggleGroupItem>
{!DatabasesWithoutDDLInstructions.includes(
databaseType
) && (
<ToggleGroupItem
value="ddl"
variant="outline"
@@ -156,19 +158,19 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
</Avatar>
SQL Script
</ToggleGroupItem>
<ToggleGroupItem
value="dbml"
variant="outline"
className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700"
>
<Avatar className="size-4 rounded-none">
<Code size={16} />
</Avatar>
DBML
</ToggleGroupItem>
</ToggleGroup>
</div>
)}
)}
<ToggleGroupItem
value="dbml"
variant="outline"
className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700"
>
<Avatar className="size-4 rounded-none">
<Code size={16} />
</Avatar>
DBML
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="flex flex-col gap-2">
<div className="text-sm font-semibold">Instructions:</div>

View File

@@ -88,6 +88,44 @@ export const useFocusOn = () => {
[fitView, setNodes, hideSidePanel, isDesktop]
);
const focusOnNote = useCallback(
(noteId: string, options: FocusOptions = {}) => {
const { select = true } = options;
if (select) {
setNodes((nodes) =>
nodes.map((node) =>
node.id === noteId
? {
...node,
selected: true,
}
: {
...node,
selected: false,
}
)
);
}
fitView({
duration: 500,
maxZoom: 1,
minZoom: 1,
nodes: [
{
id: noteId,
},
],
});
if (!isDesktop) {
hideSidePanel();
}
},
[fitView, setNodes, hideSidePanel, isDesktop]
);
const focusOnRelationship = useCallback(
(
relationshipId: string,
@@ -137,6 +175,7 @@ export const useFocusOn = () => {
return {
focusOnArea,
focusOnTable,
focusOnNote,
focusOnRelationship,
};
};

View File

@@ -10,6 +10,8 @@ import {
dataTypeDataToDataType,
sortedDataTypeMap,
supportsArrayDataType,
autoIncrementAlwaysOn,
requiresNotNull,
} from '@/lib/data/data-types/data-types';
import { generateDBFieldSuffix } from '@/lib/domain/db-field';
import type { DataTypeData } from '@/lib/data/data-types/data-types';
@@ -224,12 +226,17 @@ export const useUpdateTableField = (
}
}
const newTypeName = dataType?.name ?? (value as string);
const typeRequiresNotNull = requiresNotNull(newTypeName);
const shouldForceIncrement = autoIncrementAlwaysOn(newTypeName);
updateField(table.id, field.id, {
characterMaximumLength,
precision,
scale,
isArray,
increment: undefined,
...(typeRequiresNotNull ? { nullable: false } : {}),
increment: shouldForceIncrement ? true : undefined,
default: undefined,
type: dataTypeDataToDataType(
dataType ?? {
@@ -267,9 +274,16 @@ export const useUpdateTableField = (
const debouncedNullableUpdate = useDebounce(
useCallback(
(value: boolean) => {
updateField(table.id, field.id, { nullable: value });
const updates: Partial<DBField> = { nullable: value };
// If setting to nullable, clear increment (auto-increment requires NOT NULL)
if (value && field.increment) {
updates.increment = undefined;
}
updateField(table.id, field.id, updates);
},
[updateField, table.id, field.id]
[updateField, table.id, field.id, field.increment]
),
100 // 100ms debounce for toggle
);

View File

@@ -7,9 +7,9 @@ export const ar: LanguageTranslation = {
browse: 'تصفح',
tables: 'الجداول',
refs: 'المراجع',
areas: 'المناطق',
dependencies: 'التبعيات',
custom_types: 'الأنواع المخصصة',
visuals: 'مرئيات',
},
menu: {
actions: {
@@ -232,6 +232,33 @@ export const ar: LanguageTranslation = {
},
},
visuals_section: {
visuals: 'مرئيات',
tabs: {
areas: 'Areas',
notes: 'ملاحظات',
},
},
notes_section: {
filter: 'تصفية',
add_note: 'إضافة ملاحظة',
no_results: 'لم يتم العثور على ملاحظات',
clear: 'مسح التصفية',
empty_state: {
title: 'لا توجد ملاحظات',
description: 'أنشئ ملاحظة لإضافة تعليقات نصية على اللوحة',
},
note: {
empty_note: 'ملاحظة فارغة',
note_actions: {
title: 'إجراءات الملاحظة',
edit_content: 'تحرير المحتوى',
delete_note: 'حذف الملاحظة',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -308,7 +335,7 @@ export const ar: LanguageTranslation = {
cancel: 'إلغاء',
import_from_file: 'استيراد من ملف',
back: 'رجوع',
empty_diagram: 'مخطط فارغ',
empty_diagram: 'قاعدة بيانات فارغة',
continue: 'متابعة',
import: 'استيراد',
},
@@ -479,6 +506,7 @@ export const ar: LanguageTranslation = {
new_relationship: 'علاقة جديدة',
// TODO: Translate
new_area: 'New Area',
new_note: 'ملاحظة جديدة',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const bn: LanguageTranslation = {
browse: 'ব্রাউজ',
tables: 'টেবিল',
refs: 'রেফস',
areas: 'এলাকা',
dependencies: 'নির্ভরতা',
custom_types: 'কাস্টম টাইপ',
visuals: 'ভিজ্যুয়াল',
},
menu: {
actions: {
@@ -233,6 +233,35 @@ export const bn: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: 'ভিজ্যুয়াল',
tabs: {
areas: 'Areas',
notes: 'নোট',
},
},
notes_section: {
filter: 'ফিল্টার',
add_note: 'নোট যোগ করুন',
no_results: 'কোনো নোট পাওয়া যায়নি',
clear: 'ফিল্টার সাফ করুন',
empty_state: {
title: 'কোনো নোট নেই',
description:
'ক্যানভাসে টেক্সট টীকা যোগ করতে একটি নোট তৈরি করুন',
},
note: {
empty_note: 'খালি নোট',
note_actions: {
title: 'নোট ক্রিয়া',
edit_content: 'বিষয়বস্তু সম্পাদনা',
delete_note: 'নোট মুছুন',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -310,7 +339,7 @@ export const bn: LanguageTranslation = {
cancel: 'বাতিল করুন',
back: 'ফিরে যান',
import_from_file: 'ফাইল থেকে আমদানি করুন',
empty_diagram: 'ফাঁকা চিত্র',
empty_diagram: 'খালি ডাটাবেস',
continue: 'চালিয়ে যান',
import: 'আমদানি করুন',
},
@@ -484,6 +513,7 @@ export const bn: LanguageTranslation = {
new_relationship: 'নতুন সম্পর্ক',
// TODO: Translate
new_area: 'New Area',
new_note: 'নতুন নোট',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const de: LanguageTranslation = {
browse: 'Durchsuchen',
tables: 'Tabellen',
refs: 'Refs',
areas: 'Bereiche',
dependencies: 'Abhängigkeiten',
custom_types: 'Benutzerdefinierte Typen',
visuals: 'Darstellungen',
},
menu: {
actions: {
@@ -234,6 +234,35 @@ export const de: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: 'Darstellungen',
tabs: {
areas: 'Areas',
notes: 'Notizen',
},
},
notes_section: {
filter: 'Filter',
add_note: 'Notiz hinzufügen',
no_results: 'Keine Notizen gefunden',
clear: 'Filter löschen',
empty_state: {
title: 'Keine Notizen',
description:
'Erstellen Sie eine Notiz, um Textanmerkungen auf der Leinwand hinzuzufügen',
},
note: {
empty_note: 'Leere Notiz',
note_actions: {
title: 'Notiz-Aktionen',
edit_content: 'Inhalt bearbeiten',
delete_note: 'Notiz löschen',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -313,7 +342,7 @@ export const de: LanguageTranslation = {
back: 'Zurück',
// TODO: Translate
import_from_file: 'Import from File',
empty_diagram: 'Leeres Diagramm',
empty_diagram: 'Leere Datenbank',
continue: 'Weiter',
import: 'Importieren',
},
@@ -487,6 +516,7 @@ export const de: LanguageTranslation = {
new_relationship: 'Neue Beziehung',
// TODO: Translate
new_area: 'New Area',
new_note: 'Neue Notiz',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const en = {
browse: 'Browse',
tables: 'Tables',
refs: 'Refs',
areas: 'Areas',
dependencies: 'Dependencies',
custom_types: 'Custom Types',
visuals: 'Visuals',
},
menu: {
actions: {
@@ -227,6 +227,34 @@ export const en = {
},
},
visuals_section: {
visuals: 'Visuals',
tabs: {
areas: 'Areas',
notes: 'Notes',
},
},
notes_section: {
filter: 'Filter',
add_note: 'Add Note',
no_results: 'No notes found',
clear: 'Clear Filter',
empty_state: {
title: 'No Notes',
description:
'Create a note to add text annotations on the canvas',
},
note: {
empty_note: 'Empty note',
note_actions: {
title: 'Note Actions',
edit_content: 'Edit Content',
delete_note: 'Delete Note',
},
},
},
custom_types_section: {
custom_types: 'Custom Types',
filter: 'Filter',
@@ -301,7 +329,7 @@ export const en = {
cancel: 'Cancel',
import_from_file: 'Import from File',
back: 'Back',
empty_diagram: 'Empty diagram',
empty_diagram: 'Empty database',
continue: 'Continue',
import: 'Import',
},
@@ -473,6 +501,7 @@ export const en = {
new_view: 'New View',
new_relationship: 'New Relationship',
new_area: 'New Area',
new_note: 'New Note',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const es: LanguageTranslation = {
browse: 'Examinar',
tables: 'Tablas',
refs: 'Refs',
areas: 'Áreas',
dependencies: 'Dependencias',
custom_types: 'Tipos Personalizados',
visuals: 'Visuales',
},
menu: {
actions: {
@@ -232,6 +232,35 @@ export const es: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: 'Visuales',
tabs: {
areas: 'Areas',
notes: 'Notas',
},
},
notes_section: {
filter: 'Filtrar',
add_note: 'Agregar Nota',
no_results: 'No se encontraron notas',
clear: 'Limpiar Filtro',
empty_state: {
title: 'Sin Notas',
description:
'Crea una nota para agregar anotaciones de texto en el lienzo',
},
note: {
empty_note: 'Nota vacía',
note_actions: {
title: 'Acciones de Nota',
edit_content: 'Editar Contenido',
delete_note: 'Eliminar Nota',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -310,7 +339,7 @@ export const es: LanguageTranslation = {
back: 'Atrás',
// TODO: Translate
import_from_file: 'Import from File',
empty_diagram: 'Diagrama vacío',
empty_diagram: 'Base de datos vacía',
continue: 'Continuar',
import: 'Importar',
},
@@ -486,6 +515,7 @@ export const es: LanguageTranslation = {
new_relationship: 'Nueva Relación',
// TODO: Translate
new_area: 'New Area',
new_note: 'Nueva Nota',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const fr: LanguageTranslation = {
browse: 'Parcourir',
tables: 'Tables',
refs: 'Refs',
areas: 'Zones',
dependencies: 'Dépendances',
custom_types: 'Types Personnalisés',
visuals: 'Visuels',
},
menu: {
actions: {
@@ -230,6 +230,35 @@ export const fr: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: 'Visuels',
tabs: {
areas: 'Areas',
notes: 'Notes',
},
},
notes_section: {
filter: 'Filtrer',
add_note: 'Ajouter une Note',
no_results: 'Aucune note trouvée',
clear: 'Effacer le Filtre',
empty_state: {
title: 'Pas de Notes',
description:
'Créez une note pour ajouter des annotations de texte sur le canevas',
},
note: {
empty_note: 'Note vide',
note_actions: {
title: 'Actions de Note',
edit_content: 'Modifier le Contenu',
delete_note: 'Supprimer la Note',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -307,7 +336,7 @@ export const fr: LanguageTranslation = {
cancel: 'Annuler',
back: 'Retour',
import_from_file: "Importer à partir d'un fichier",
empty_diagram: 'Diagramme vide',
empty_diagram: 'Base de données vide',
continue: 'Continuer',
import: 'Importer',
},
@@ -482,6 +511,7 @@ export const fr: LanguageTranslation = {
new_relationship: 'Nouvelle Relation',
// TODO: Translate
new_area: 'New Area',
new_note: 'Nouvelle Note',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const gu: LanguageTranslation = {
browse: 'બ્રાઉજ',
tables: 'ટેબલો',
refs: 'રેફ્સ',
areas: 'ક્ષેત્રો',
dependencies: 'નિર્ભરતાઓ',
custom_types: 'કસ્ટમ ટાઇપ',
visuals: 'Visuals',
},
menu: {
actions: {
@@ -234,6 +234,35 @@ export const gu: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: 'Visuals',
tabs: {
areas: 'Areas',
notes: 'નોંધો',
},
},
notes_section: {
filter: 'ફિલ્ટર',
add_note: 'નોંધ ઉમેરો',
no_results: 'કોઈ નોંધો મળી નથી',
clear: 'ફિલ્ટર સાફ કરો',
empty_state: {
title: 'કોઈ નોંધો નથી',
description:
'કેનવાસ પર ટેક્સ્ટ એનોટેશન ઉમેરવા માટે નોંધ બનાવો',
},
note: {
empty_note: 'ખાલી નોંધ',
note_actions: {
title: 'નોંધ ક્રિયાઓ',
edit_content: 'સામગ્રી સંપાદિત કરો',
delete_note: 'નોંધ કાઢી નાખો',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -310,7 +339,7 @@ export const gu: LanguageTranslation = {
cancel: 'રદ કરો',
back: 'પાછા',
import_from_file: 'ફાઇલમાંથી આયાત કરો',
empty_diagram: 'ખાલી ડાયાગ્રામ',
empty_diagram: 'ખાલી ડેટાબેસ',
continue: 'ચાલુ રાખો',
import: 'આયાત કરો',
},
@@ -485,6 +514,7 @@ export const gu: LanguageTranslation = {
new_relationship: 'નવો સંબંધ',
// TODO: Translate
new_area: 'New Area',
new_note: 'નવી નોંધ',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const hi: LanguageTranslation = {
browse: 'ब्राउज़',
tables: 'टेबल',
refs: 'रेफ्स',
areas: 'क्षेत्र',
dependencies: 'निर्भरताएं',
custom_types: 'कस्टम टाइप',
visuals: 'Visuals',
},
menu: {
actions: {
@@ -233,6 +233,35 @@ export const hi: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: 'Visuals',
tabs: {
areas: 'Areas',
notes: 'नोट्स',
},
},
notes_section: {
filter: 'फ़िल्टर',
add_note: 'नोट जोड़ें',
no_results: 'कोई नोट नहीं मिला',
clear: 'फ़िल्टर साफ़ करें',
empty_state: {
title: 'कोई नोट नहीं',
description:
'कैनवास पर टेक्स्ट एनोटेशन जोड़ने के लिए एक नोट बनाएं',
},
note: {
empty_note: 'खाली नोट',
note_actions: {
title: 'नोट क्रियाएं',
edit_content: 'सामग्री संपादित करें',
delete_note: 'नोट हटाएं',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -312,7 +341,7 @@ export const hi: LanguageTranslation = {
back: 'वापस',
// TODO: Translate
import_from_file: 'Import from File',
empty_diagram: 'खाली आरेख',
empty_diagram: 'खाली डेटाबेस',
continue: 'जारी रखें',
import: 'आयात करें',
},
@@ -487,6 +516,7 @@ export const hi: LanguageTranslation = {
new_relationship: 'नया संबंध',
// TODO: Translate
new_area: 'New Area',
new_note: 'नया नोट',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const hr: LanguageTranslation = {
browse: 'Pregledaj',
tables: 'Tablice',
refs: 'Refs',
areas: 'Područja',
dependencies: 'Ovisnosti',
custom_types: 'Prilagođeni Tipovi',
visuals: 'Vizuali',
},
menu: {
actions: {
@@ -229,6 +229,34 @@ export const hr: LanguageTranslation = {
},
},
visuals_section: {
visuals: 'Vizuali',
tabs: {
areas: 'Područja',
notes: 'Bilješke',
},
},
notes_section: {
filter: 'Filtriraj',
add_note: 'Dodaj Bilješku',
no_results: 'Nije pronađena nijedna bilješka',
clear: 'Očisti Filter',
empty_state: {
title: 'Nema Bilješki',
description:
'Kreirajte bilješku za dodavanje tekstualnih napomena na platnu',
},
note: {
empty_note: 'Prazna bilješka',
note_actions: {
title: 'Akcije Bilješke',
edit_content: 'Uredi Sadržaj',
delete_note: 'Obriši Bilješku',
},
},
},
custom_types_section: {
custom_types: 'Prilagođeni tipovi',
filter: 'Filtriraj',
@@ -305,7 +333,7 @@ export const hr: LanguageTranslation = {
cancel: 'Odustani',
import_from_file: 'Uvezi iz datoteke',
back: 'Natrag',
empty_diagram: 'Prazan dijagram',
empty_diagram: 'Prazna baza podataka',
continue: 'Nastavi',
import: 'Uvezi',
},
@@ -478,6 +506,7 @@ export const hr: LanguageTranslation = {
new_view: 'Novi Pogled',
new_relationship: 'Nova veza',
new_area: 'Novo područje',
new_note: 'Nova Bilješka',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const id_ID: LanguageTranslation = {
browse: 'Jelajahi',
tables: 'Tabel',
refs: 'Refs',
areas: 'Area',
dependencies: 'Ketergantungan',
custom_types: 'Tipe Kustom',
visuals: 'Visual',
},
menu: {
actions: {
@@ -232,6 +232,35 @@ export const id_ID: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: 'Visual',
tabs: {
areas: 'Areas',
notes: 'Catatan',
},
},
notes_section: {
filter: 'Filter',
add_note: 'Tambah Catatan',
no_results: 'Tidak ada catatan ditemukan',
clear: 'Hapus Filter',
empty_state: {
title: 'Tidak Ada Catatan',
description:
'Buat catatan untuk menambahkan anotasi teks di kanvas',
},
note: {
empty_note: 'Catatan kosong',
note_actions: {
title: 'Aksi Catatan',
edit_content: 'Edit Konten',
delete_note: 'Hapus Catatan',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -309,7 +338,7 @@ export const id_ID: LanguageTranslation = {
cancel: 'Batal',
import_from_file: 'Impor dari file',
back: 'Kembali',
empty_diagram: 'Diagram Kosong',
empty_diagram: 'Database Kosong',
continue: 'Lanjutkan',
import: 'Impor',
},
@@ -484,6 +513,7 @@ export const id_ID: LanguageTranslation = {
new_relationship: 'Hubungan Baru',
// TODO: Translate
new_area: 'New Area',
new_note: 'Catatan Baru',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const ja: LanguageTranslation = {
browse: '参照',
tables: 'テーブル',
refs: '参照',
areas: 'エリア',
dependencies: '依存関係',
custom_types: 'カスタムタイプ',
visuals: 'ビジュアル',
},
menu: {
actions: {
@@ -237,6 +237,35 @@ export const ja: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: 'ビジュアル',
tabs: {
areas: 'Areas',
notes: 'ノート',
},
},
notes_section: {
filter: 'フィルター',
add_note: 'ノートを追加',
no_results: 'ノートが見つかりません',
clear: 'フィルターをクリア',
empty_state: {
title: 'ノートがありません',
description:
'キャンバス上にテキスト注釈を追加するためのノートを作成',
},
note: {
empty_note: '空のノート',
note_actions: {
title: 'ノートアクション',
edit_content: 'コンテンツを編集',
delete_note: 'ノートを削除',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -314,7 +343,7 @@ export const ja: LanguageTranslation = {
back: '戻る',
// TODO: Translate
import_from_file: 'Import from File',
empty_diagram: '空のダイアグラム',
empty_diagram: '空のデータベース',
continue: '続行',
import: 'インポート',
},
@@ -489,6 +518,7 @@ export const ja: LanguageTranslation = {
new_relationship: '新しいリレーションシップ',
// TODO: Translate
new_area: 'New Area',
new_note: '新しいメモ',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const ko_KR: LanguageTranslation = {
browse: '찾아보기',
tables: '테이블',
refs: 'Refs',
areas: '영역',
dependencies: '종속성',
custom_types: '사용자 지정 타입',
visuals: '시각화',
},
menu: {
actions: {
@@ -232,6 +232,35 @@ export const ko_KR: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: '시각화',
tabs: {
areas: 'Areas',
notes: '메모',
},
},
notes_section: {
filter: '필터',
add_note: '메모 추가',
no_results: '메모를 찾을 수 없습니다',
clear: '필터 지우기',
empty_state: {
title: '메모 없음',
description:
'캔버스에 텍스트 주석을 추가하려면 메모를 만드세요',
},
note: {
empty_note: '빈 메모',
note_actions: {
title: '메모 작업',
edit_content: '내용 편집',
delete_note: '메모 삭제',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -309,7 +338,7 @@ export const ko_KR: LanguageTranslation = {
cancel: '취소',
back: '뒤로가기',
import_from_file: '파일에서 가져오기',
empty_diagram: '빈 다이어그램으로 시작',
empty_diagram: '빈 데이터베이스',
continue: '계속',
import: '가져오기',
},
@@ -481,6 +510,7 @@ export const ko_KR: LanguageTranslation = {
new_relationship: '새 연관관계',
// TODO: Translate
new_area: 'New Area',
new_note: '새 메모',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const mr: LanguageTranslation = {
browse: 'ब्राउज',
tables: 'टेबल',
refs: 'Refs',
areas: 'क्षेत्रे',
dependencies: 'अवलंबने',
custom_types: 'कस्टम प्रकार',
visuals: 'Visuals',
},
menu: {
actions: {
@@ -236,6 +236,35 @@ export const mr: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: 'Visuals',
tabs: {
areas: 'Areas',
notes: 'नोट्स',
},
},
notes_section: {
filter: 'फिल्टर',
add_note: 'नोट जोडा',
no_results: 'कोणत्याही नोट्स सापडल्या नाहीत',
clear: 'फिल्टर साफ करा',
empty_state: {
title: 'नोट्स नाहीत',
description:
'कॅनव्हासवर मजकूर भाष्य जोडण्यासाठी एक नोट तयार करा',
},
note: {
empty_note: 'रिकामी नोट',
note_actions: {
title: 'नोट क्रिया',
edit_content: 'सामग्री संपादित करा',
delete_note: 'नोट हटवा',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -315,7 +344,7 @@ export const mr: LanguageTranslation = {
// TODO: Add translations
import_from_file: 'Import from File',
back: 'मागे',
empty_diagram: 'रिक्त आरेख',
empty_diagram: 'रिक्त डेटाबेस',
continue: 'सुरू ठेवा',
import: 'आयात करा',
},
@@ -493,6 +522,7 @@ export const mr: LanguageTranslation = {
new_relationship: 'नवीन रिलेशनशिप',
// TODO: Translate
new_area: 'New Area',
new_note: 'नवीन टीप',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const ne: LanguageTranslation = {
browse: 'ब्राउज',
tables: 'टेबलहरू',
refs: 'Refs',
areas: 'क्षेत्रहरू',
dependencies: 'निर्भरताहरू',
custom_types: 'कस्टम प्रकारहरू',
visuals: 'Visuals',
},
menu: {
actions: {
@@ -233,6 +233,35 @@ export const ne: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: 'Visuals',
tabs: {
areas: 'Areas',
notes: 'टिप्पणीहरू',
},
},
notes_section: {
filter: 'फिल्टर',
add_note: 'टिप्पणी थप्नुहोस्',
no_results: 'कुनै टिप्पणी फेला परेन',
clear: 'फिल्टर खाली गर्नुहोस्',
empty_state: {
title: 'कुनै टिप्पणी छैन',
description:
'क्यानभासमा पाठ टिप्पणी थप्न टिप्पणी सिर्जना गर्नुहोस्',
},
note: {
empty_note: 'खाली टिप्पणी',
note_actions: {
title: 'टिप्पणी कार्यहरू',
edit_content: 'सामग्री सम्पादन गर्नुहोस्',
delete_note: 'टिप्पणी मेटाउनुहोस्',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -311,7 +340,7 @@ export const ne: LanguageTranslation = {
cancel: 'रद्द गर्नुहोस्',
import_from_file: 'फाइलबाट आयात गर्नुहोस्',
back: 'फर्क',
empty_diagram: 'रिक्त डायाग्राम',
empty_diagram: 'खाली डाटाबेस',
continue: 'जारी राख्नुहोस्',
import: 'आयात गर्नुहोस्',
},
@@ -487,6 +516,7 @@ export const ne: LanguageTranslation = {
new_relationship: 'नयाँ सम्बन्ध',
// TODO: Translate
new_area: 'New Area',
new_note: 'नयाँ नोट',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const pt_BR: LanguageTranslation = {
browse: 'Navegar',
tables: 'Tabelas',
refs: 'Refs',
areas: 'Áreas',
dependencies: 'Dependências',
custom_types: 'Tipos Personalizados',
visuals: 'Visuais',
},
menu: {
actions: {
@@ -233,6 +233,35 @@ export const pt_BR: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: 'Visuais',
tabs: {
areas: 'Areas',
notes: 'Notas',
},
},
notes_section: {
filter: 'Filtrar',
add_note: 'Adicionar Nota',
no_results: 'Nenhuma nota encontrada',
clear: 'Limpar Filtro',
empty_state: {
title: 'Sem Notas',
description:
'Crie uma nota para adicionar anotações de texto na tela',
},
note: {
empty_note: 'Nota vazia',
note_actions: {
title: 'Ações de Nota',
edit_content: 'Editar Conteúdo',
delete_note: 'Excluir Nota',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -311,7 +340,7 @@ export const pt_BR: LanguageTranslation = {
back: 'Voltar',
// TODO: Translate
import_from_file: 'Import from File',
empty_diagram: 'Diagrama vazio',
empty_diagram: 'Banco de dados vazio',
continue: 'Continuar',
import: 'Importar',
},
@@ -486,6 +515,7 @@ export const pt_BR: LanguageTranslation = {
new_relationship: 'Novo Relacionamento',
// TODO: Translate
new_area: 'New Area',
new_note: 'Nova Nota',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const ru: LanguageTranslation = {
browse: 'Обзор',
tables: 'Таблицы',
refs: 'Ссылки',
areas: 'Области',
dependencies: 'Зависимости',
custom_types: 'Пользовательские типы',
visuals: 'Визуальные элементы',
},
menu: {
actions: {
@@ -230,6 +230,35 @@ export const ru: LanguageTranslation = {
description: 'Создайте область, чтобы начать',
},
},
visuals_section: {
visuals: 'Визуальные элементы',
tabs: {
areas: 'Области',
notes: 'Заметки',
},
},
notes_section: {
filter: 'Фильтр',
add_note: 'Добавить Заметку',
no_results: 'Заметки не найдены',
clear: 'Очистить Фильтр',
empty_state: {
title: 'Нет Заметок',
description:
'Создайте заметку, чтобы добавить текстовые аннотации на холсте',
},
note: {
empty_note: 'Пустая заметка',
note_actions: {
title: 'Действия с Заметкой',
edit_content: 'Редактировать Содержимое',
delete_note: 'Удалить Заметку',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -307,7 +336,7 @@ export const ru: LanguageTranslation = {
cancel: 'Отменить',
back: 'Назад',
import_from_file: 'Импортировать из файла',
empty_diagram: 'Пустая диаграмма',
empty_diagram: 'Пустая база данных',
continue: 'Продолжить',
import: 'Импорт',
},
@@ -481,6 +510,7 @@ export const ru: LanguageTranslation = {
new_view: 'Новое представление',
new_relationship: 'Создать отношение',
new_area: 'Новая область',
new_note: 'Новая Заметка',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const te: LanguageTranslation = {
browse: 'బ్రాఉజ్',
tables: 'టేబల్లు',
refs: 'సంబంధాలు',
areas: 'ప్రదేశాలు',
dependencies: 'ఆధారతలు',
custom_types: 'కస్టమ్ టైప్స్',
visuals: 'Visuals',
},
menu: {
actions: {
@@ -234,6 +234,35 @@ export const te: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: 'Visuals',
tabs: {
areas: 'Areas',
notes: 'గమనికలు',
},
},
notes_section: {
filter: 'ఫిల్టర్',
add_note: 'గమనిక జోడించండి',
no_results: 'గమనికలు కనుగొనబడలేదు',
clear: 'ఫిల్టర్‌ను క్లియర్ చేయండి',
empty_state: {
title: 'గమనికలు లేవు',
description:
'కాన్వాస్‌పై టెక్స్ట్ ఉల్లేఖనలను జోడించడానికి ఒక గమనికను సృష్టించండి',
},
note: {
empty_note: 'ఖాళీ గమనిక',
note_actions: {
title: 'గమనిక చర్యలు',
edit_content: 'కంటెంట్‌ను సవరించండి',
delete_note: 'గమనికను తొలగించండి',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -312,7 +341,7 @@ export const te: LanguageTranslation = {
// TODO: Translate
import_from_file: 'Import from File',
back: 'తిరుగు',
empty_diagram: 'ఖాళీ చిత్రము',
empty_diagram: 'ఖాళీ డేటాబేస్',
continue: 'కొనసాగించు',
import: 'డిగుమతి',
},
@@ -490,6 +519,7 @@ export const te: LanguageTranslation = {
new_relationship: 'కొత్త సంబంధం',
// TODO: Translate
new_area: 'New Area',
new_note: 'కొత్త నోట్',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const tr: LanguageTranslation = {
browse: 'Gözat',
tables: 'Tablolar',
refs: 'Refs',
areas: 'Alanlar',
dependencies: 'Bağımlılıklar',
custom_types: 'Özel Tipler',
visuals: 'Görseller',
},
menu: {
actions: {
@@ -233,6 +233,35 @@ export const tr: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: 'Görseller',
tabs: {
areas: 'Areas',
notes: 'Notlar',
},
},
notes_section: {
filter: 'Filtrele',
add_note: 'Not Ekle',
no_results: 'Not bulunamadı',
clear: 'Filtreyi Temizle',
empty_state: {
title: 'Not Yok',
description:
'Tuval üzerinde metin açıklamaları eklemek için bir not oluşturun',
},
note: {
empty_note: 'Boş not',
note_actions: {
title: 'Not İşlemleri',
edit_content: 'İçeriği Düzenle',
delete_note: 'Notu Sil',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -308,7 +337,7 @@ export const tr: LanguageTranslation = {
import_from_file: 'Import from File',
cancel: 'İptal',
back: 'Geri',
empty_diagram: 'Boş diyagram',
empty_diagram: 'Boş veritabanı',
continue: 'Devam',
import: 'İçe Aktar',
},
@@ -475,6 +504,7 @@ export const tr: LanguageTranslation = {
new_relationship: 'Yeni İlişki',
// TODO: Translate
new_area: 'New Area',
new_note: 'Yeni Not',
},
table_node_context_menu: {
edit_table: 'Tabloyu Düzenle',

View File

@@ -7,9 +7,9 @@ export const uk: LanguageTranslation = {
browse: 'Огляд',
tables: 'Таблиці',
refs: 'Зв’язки',
areas: 'Області',
dependencies: 'Залежності',
custom_types: 'Користувацькі типи',
visuals: 'Візуальні елементи',
},
menu: {
actions: {
@@ -231,6 +231,35 @@ export const uk: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: 'Візуальні елементи',
tabs: {
areas: 'Areas',
notes: 'Нотатки',
},
},
notes_section: {
filter: 'Фільтр',
add_note: 'Додати Нотатку',
no_results: 'Нотатки не знайдено',
clear: 'Очистити Фільтр',
empty_state: {
title: 'Немає Нотаток',
description:
'Створіть нотатку, щоб додати текстові анотації на полотні',
},
note: {
empty_note: 'Порожня нотатка',
note_actions: {
title: 'Дії з Нотаткою',
edit_content: 'Редагувати Вміст',
delete_note: 'Видалити Нотатку',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -308,7 +337,7 @@ export const uk: LanguageTranslation = {
cancel: 'Скасувати',
back: 'Назад',
import_from_file: 'Імпортувати з файлу',
empty_diagram: 'Порожня діаграма',
empty_diagram: 'Порожня база даних',
continue: 'Продовжити',
import: 'Імпорт',
},
@@ -481,6 +510,7 @@ export const uk: LanguageTranslation = {
new_relationship: 'Новий звʼязок',
// TODO: Translate
new_area: 'New Area',
new_note: 'Нова Нотатка',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const vi: LanguageTranslation = {
browse: 'Duyệt',
tables: 'Bảng',
refs: 'Refs',
areas: 'Khu vực',
dependencies: 'Phụ thuộc',
custom_types: 'Kiểu tùy chỉnh',
visuals: 'Hình ảnh',
},
menu: {
actions: {
@@ -232,6 +232,35 @@ export const vi: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: 'Hình ảnh',
tabs: {
areas: 'Areas',
notes: 'Ghi chú',
},
},
notes_section: {
filter: 'Lọc',
add_note: 'Thêm Ghi Chú',
no_results: 'Không tìm thấy ghi chú',
clear: 'Xóa Bộ Lọc',
empty_state: {
title: 'Không Có Ghi Chú',
description:
'Tạo ghi chú để thêm chú thích văn bản trên canvas',
},
note: {
empty_note: 'Ghi chú trống',
note_actions: {
title: 'Hành Động Ghi Chú',
edit_content: 'Chỉnh Sửa Nội Dung',
delete_note: 'Xóa Ghi Chú',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -309,7 +338,7 @@ export const vi: LanguageTranslation = {
cancel: 'Hủy',
import_from_file: 'Nhập từ tệp',
back: 'Trở lại',
empty_diagram: 'Sơ đồ trống',
empty_diagram: 'Cơ sở dữ liệu trống',
continue: 'Tiếp tục',
import: 'Nhập',
},
@@ -482,6 +511,7 @@ export const vi: LanguageTranslation = {
new_relationship: 'Tạo quan hệ mới',
// TODO: Translate
new_area: 'New Area',
new_note: 'Ghi Chú Mới',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const zh_CN: LanguageTranslation = {
browse: '浏览',
tables: '表',
refs: '引用',
areas: '区域',
dependencies: '依赖关系',
custom_types: '自定义类型',
visuals: '视觉效果',
},
menu: {
actions: {
@@ -229,6 +229,34 @@ export const zh_CN: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: '视觉效果',
tabs: {
areas: 'Areas',
notes: '笔记',
},
},
notes_section: {
filter: '筛选',
add_note: '添加笔记',
no_results: '未找到笔记',
clear: '清除筛选',
empty_state: {
title: '没有笔记',
description: '创建笔记以在画布上添加文本注释',
},
note: {
empty_note: '空笔记',
note_actions: {
title: '笔记操作',
edit_content: '编辑内容',
delete_note: '删除笔记',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -306,7 +334,7 @@ export const zh_CN: LanguageTranslation = {
cancel: '取消',
import_from_file: '从文件导入',
back: '上一步',
empty_diagram: '新建空关系图',
empty_diagram: '空数据库',
continue: '下一步',
import: '导入',
},
@@ -477,6 +505,7 @@ export const zh_CN: LanguageTranslation = {
new_relationship: '新建关系',
// TODO: Translate
new_area: 'New Area',
new_note: '新笔记',
},
table_node_context_menu: {

View File

@@ -7,9 +7,9 @@ export const zh_TW: LanguageTranslation = {
browse: '瀏覽',
tables: '表格',
refs: 'Refs',
areas: '區域',
dependencies: '相依性',
custom_types: '自定義類型',
visuals: '視覺效果',
},
menu: {
actions: {
@@ -229,6 +229,34 @@ export const zh_TW: LanguageTranslation = {
description: 'Create an area to get started',
},
},
visuals_section: {
visuals: '視覺效果',
tabs: {
areas: 'Areas',
notes: '筆記',
},
},
notes_section: {
filter: '篩選',
add_note: '新增筆記',
no_results: '未找到筆記',
clear: '清除篩選',
empty_state: {
title: '沒有筆記',
description: '建立筆記以在畫布上新增文字註解',
},
note: {
empty_note: '空白筆記',
note_actions: {
title: '筆記操作',
edit_content: '編輯內容',
delete_note: '刪除筆記',
},
},
},
// TODO: Translate
custom_types_section: {
custom_types: 'Custom Types',
@@ -305,7 +333,7 @@ export const zh_TW: LanguageTranslation = {
cancel: '取消',
import_from_file: '從檔案匯入',
back: '返回',
empty_diagram: '空白圖表',
empty_diagram: '空資料庫',
continue: '繼續',
import: '匯入',
},
@@ -477,6 +505,7 @@ export const zh_TW: LanguageTranslation = {
new_relationship: '新建關聯',
// TODO: Translate
new_area: 'New Area',
new_note: '新筆記',
},
table_node_context_menu: {

View File

@@ -6,6 +6,7 @@ import type { DBIndex } from './domain/db-index';
import type { DBRelationship } from './domain/db-relationship';
import type { DBTable } from './domain/db-table';
import type { Diagram } from './domain/diagram';
import type { Note } from './domain/note';
import { generateId as defaultGenerateId } from './utils';
const generateIdsMapFromTable = (
@@ -49,6 +50,10 @@ const generateIdsMapFromDiagram = (
idsMap.set(area.id, generateId());
});
diagram.notes?.forEach((note) => {
idsMap.set(note.id, generateId());
});
diagram.customTypes?.forEach((customType) => {
idsMap.set(customType.id, generateId());
});
@@ -218,6 +223,21 @@ export const cloneDiagram = (
})
.filter((area): area is Area => area !== null) ?? [];
const notes: Note[] =
diagram.notes
?.map((note) => {
const id = getNewId(note.id);
if (!id) {
return null;
}
return {
...note,
id,
} satisfies Note;
})
.filter((note): note is Note => note !== null) ?? [];
const customTypes: DBCustomType[] =
diagram.customTypes
?.map((customType) => {
@@ -242,6 +262,7 @@ export const cloneDiagram = (
relationships,
tables,
areas,
notes,
customTypes,
createdAt: diagram.createdAt
? new Date(diagram.createdAt)

View File

@@ -167,6 +167,18 @@ export const supportsAutoIncrementDataType = (
].includes(dataTypeName.toLocaleLowerCase());
};
export const autoIncrementAlwaysOn = (dataTypeName: string): boolean => {
return ['serial', 'bigserial', 'smallserial'].includes(
dataTypeName.toLowerCase()
);
};
export const requiresNotNull = (dataTypeName: string): boolean => {
return ['serial', 'bigserial', 'smallserial'].includes(
dataTypeName.toLowerCase()
);
};
const ARRAY_INCOMPATIBLE_TYPES = [
'serial',
'bigserial',

View File

@@ -60,6 +60,7 @@ export const createFieldsFromMetadata = ({
...(col.is_identity !== undefined
? { increment: col.is_identity }
: {}),
...(col.is_array !== undefined ? { isArray: col.is_array } : {}),
createdAt: Date.now(),
comments: col.comment ? col.comment : undefined,
})

View File

@@ -64,7 +64,7 @@ export const loadFromDatabaseMetadata = async ({
const diagram: Diagram = {
id: generateDiagramId(),
name: databaseMetadata.database_name
? `${databaseMetadata.database_name}-db`
? `${databaseMetadata.database_name}`
: diagramNumber
? `Diagram ${diagramNumber}`
: 'New Diagram',

View File

@@ -15,7 +15,8 @@ export interface ColumnInfo {
default?: string | null; // Default value for the column, nullable
collation?: string | null;
comment?: string | null;
is_identity?: boolean; // Indicates if the column is auto-increment/identity
is_identity?: boolean | null; // Indicates if the column is auto-increment/identity
is_array?: boolean | null; // Indicates if the column is an array type
}
export const ColumnInfoSchema: z.ZodType<ColumnInfo> = z.object({
@@ -36,5 +37,6 @@ export const ColumnInfoSchema: z.ZodType<ColumnInfo> = z.object({
default: z.string().nullable().optional(),
collation: z.string().nullable().optional(),
comment: z.string().nullable().optional(),
is_identity: z.boolean().optional(),
is_identity: z.boolean().nullable().optional(),
is_array: z.boolean().nullable().optional(),
});

View File

@@ -181,7 +181,13 @@ cols AS (
'","table":"', cols.table_name,
'","name":"', cols.column_name,
'","ordinal_position":', cols.ordinal_position,
',"type":"', case when LOWER(replace(cols.data_type, '"', '')) = 'user-defined' then pg_type.typname else LOWER(replace(cols.data_type, '"', '')) end,
',"type":"', CASE WHEN cols.data_type = 'ARRAY' THEN
format_type(pg_type.typelem, NULL)
WHEN LOWER(replace(cols.data_type, '"', '')) = 'user-defined' THEN
format_type(pg_type.oid, NULL)
ELSE
LOWER(replace(cols.data_type, '"', ''))
END,
'","character_maximum_length":"', COALESCE(cols.character_maximum_length::text, 'null'),
'","precision":',
CASE
@@ -194,11 +200,15 @@ cols AS (
',"default":"', ${withExtras ? withDefault : withoutDefault},
'","collation":"', COALESCE(cols.COLLATION_NAME, ''),
'","comment":"', ${withExtras ? withComments : withoutComments},
'","is_identity":', CASE
'","is_identity":', CASE
WHEN cols.is_identity = 'YES' THEN 'true'
WHEN cols.column_default IS NOT NULL AND cols.column_default LIKE 'nextval(%' THEN 'true'
ELSE 'false'
END,
',"is_array":', CASE
WHEN cols.data_type = 'ARRAY' OR pg_type.typelem > 0 THEN 'true'
ELSE 'false'
END,
'}')), ',') AS cols_metadata
FROM information_schema.columns cols
LEFT JOIN pg_catalog.pg_class c
@@ -211,6 +221,8 @@ cols AS (
ON attr.attrelid = c.oid AND attr.attname = cols.column_name
LEFT JOIN pg_catalog.pg_type
ON pg_type.oid = attr.atttypid
LEFT JOIN pg_catalog.pg_type AS elem_type
ON elem_type.oid = pg_type.typelem
WHERE cols.table_schema NOT IN ('information_schema', 'pg_catalog')${
databaseEdition === DatabaseEdition.POSTGRESQL_TIMESCALE
? timescaleColFilter

View File

@@ -286,10 +286,14 @@ export function exportPostgreSQL({
}
}
// Handle array types (check if the type name ends with '[]')
if (typeName.endsWith('[]')) {
typeWithSize =
typeWithSize.replace('[]', '') + '[]';
// Handle array types (check if isArray flag or if type name ends with '[]')
if (field.isArray || typeName.endsWith('[]')) {
// Remove any existing [] notation
const baseTypeWithoutArray = typeWithSize.replace(
/\[\]$/,
''
);
typeWithSize = baseTypeWithoutArray + '[]';
}
const notNull = field.nullable ? '' : ' NOT NULL';

View File

@@ -338,7 +338,13 @@ export const exportBaseSQL = ({
const quotedFieldName = getQuotedFieldName(field.name, isDBMLFlow);
sqlScript += ` ${quotedFieldName} ${typeName}`;
// Quote multi-word type names for DBML flow to prevent @dbml/core parser issues
const quotedTypeName =
isDBMLFlow && typeName.includes(' ')
? `"${typeName}"`
: typeName;
sqlScript += ` ${quotedFieldName} ${quotedTypeName}`;
// Add size for character types
if (
@@ -395,9 +401,26 @@ export const exportBaseSQL = ({
sqlScript += ` UNIQUE`;
}
// Handle AUTO INCREMENT - add as a comment for AI to process
// Handle AUTO INCREMENT
if (field.increment) {
sqlScript += ` /* AUTO_INCREMENT */`;
if (isDBMLFlow) {
// For DBML flow, generate proper database-specific syntax
if (
targetDatabaseType === DatabaseType.MYSQL ||
targetDatabaseType === DatabaseType.MARIADB
) {
sqlScript += ` AUTO_INCREMENT`;
} else if (targetDatabaseType === DatabaseType.SQL_SERVER) {
sqlScript += ` IDENTITY(1,1)`;
} else if (targetDatabaseType === DatabaseType.SQLITE) {
// SQLite AUTOINCREMENT only works with INTEGER PRIMARY KEY
// Will be handled when PRIMARY KEY is added
}
// PostgreSQL/CockroachDB: increment attribute added by restoreIncrementAttribute in DBML export
} else {
// For non-DBML flow, add as a comment for AI to process
sqlScript += ` /* AUTO_INCREMENT */`;
}
}
// Handle DEFAULT value
@@ -450,6 +473,17 @@ export const exportBaseSQL = ({
const pkIndex = table.indexes.find((idx) => idx.isPrimaryKey);
if (field.primaryKey && !hasCompositePrimaryKey && !pkIndex?.name) {
sqlScript += ' PRIMARY KEY';
// For SQLite with DBML flow, add AUTOINCREMENT after PRIMARY KEY
if (
isDBMLFlow &&
field.increment &&
targetDatabaseType === DatabaseType.SQLITE &&
(typeName.toLowerCase() === 'integer' ||
typeName.toLowerCase() === 'int')
) {
sqlScript += ' AUTOINCREMENT';
}
}
// Add a comma after each field except the last one (or before PK constraint)

View File

@@ -620,6 +620,7 @@ export const applyDBMLChanges = ({
...sourceDiagram,
tables: finalTables.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
areas: targetDiagram.areas,
notes: targetDiagram.notes,
relationships: sortedRelationships,
dependencies: updatedDependencies,
customTypes: updatedCustomTypes,

View File

@@ -1,6 +1,6 @@
Table "public"."guy_table" {
"id" integer [pk, not null]
"created_at" timestamp [not null]
"created_at" "timestamp without time zone" [not null]
"column3" text
"arrayfield" text[]
"field_5" "character varying"

View File

@@ -1,5 +1,5 @@
Table "public"."orders" {
"order_id" integer [pk, not null]
"order_id" integer [pk, not null, increment]
"customer_id" integer [not null]
"order_date" date [not null, default: `CURRENT_DATE`]
"total_amount" numeric [not null, default: 0]

View File

@@ -0,0 +1,14 @@
Table "users" {
"id" integer [pk, not null, increment]
"username" varchar(100) [unique, not null]
"email" varchar(255) [not null]
}
Table "posts" {
"post_id" bigint [pk, not null, increment]
"user_id" integer [not null]
"title" varchar(200) [not null]
"order_num" integer [not null, increment]
}
Ref "fk_0_fk_posts_users":"users"."id" < "posts"."user_id"

View File

@@ -0,0 +1 @@
{"id":"test_auto_increment","name":"Auto Increment Test (mysql)","createdAt":"2025-01-20T00:00:00.000Z","updatedAt":"2025-01-20T00:00:00.000Z","databaseType":"mysql","tables":[{"id":"table1","name":"users","order":1,"fields":[{"id":"field1","name":"id","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":true,"unique":false,"default":"","increment":true,"createdAt":1705708800000},{"id":"field2","name":"username","type":{"id":"varchar","name":"varchar","fieldAttributes":{"hasCharMaxLength":true}},"nullable":false,"primaryKey":false,"unique":true,"default":"","increment":false,"characterMaximumLength":"100","createdAt":1705708800000},{"id":"field3","name":"email","type":{"id":"varchar","name":"varchar","fieldAttributes":{"hasCharMaxLength":true}},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":false,"characterMaximumLength":"255","createdAt":1705708800000}],"indexes":[],"x":100,"y":100,"color":"#8eb7ff","isView":false,"createdAt":1705708800000},{"id":"table2","name":"posts","order":2,"fields":[{"id":"field4","name":"post_id","type":{"id":"bigint","name":"bigint"},"nullable":false,"primaryKey":true,"unique":false,"default":"","increment":true,"createdAt":1705708800000},{"id":"field5","name":"user_id","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":false,"createdAt":1705708800000},{"id":"field6","name":"title","type":{"id":"varchar","name":"varchar","fieldAttributes":{"hasCharMaxLength":true}},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":false,"characterMaximumLength":"200","createdAt":1705708800000},{"id":"field7","name":"order_num","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":true,"createdAt":1705708800000}],"indexes":[],"x":300,"y":100,"color":"#8eb7ff","isView":false,"createdAt":1705708800000}],"relationships":[{"id":"rel1","name":"fk_posts_users","sourceTableId":"table2","targetTableId":"table1","sourceFieldId":"field5","targetFieldId":"field1","type":"one_to_many","sourceCardinality":"many","targetCardinality":"one","createdAt":1705708800000}],"dependencies":[],"storageMode":"project","areas":[],"creationMethod":"manual","customTypes":[]}

View File

@@ -0,0 +1,205 @@
import { describe, it, expect } from 'vitest';
import { generateDBMLFromDiagram } from '../dbml-export';
import { DatabaseType } from '@/lib/domain/database-type';
import type { Diagram } from '@/lib/domain/diagram';
import { generateId, generateDiagramId } from '@/lib/utils';
describe('DBML Export - Empty Tables', () => {
it('should filter out tables with no fields', () => {
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'valid_table',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'empty_table',
schema: 'public',
x: 0,
y: 0,
fields: [], // Empty fields array
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'another_valid_table',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'name',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateDBMLFromDiagram(diagram);
// Verify the DBML doesn't contain the empty table
expect(result.inlineDbml).not.toContain('empty_table');
expect(result.standardDbml).not.toContain('empty_table');
// Verify the valid tables are still present
expect(result.inlineDbml).toContain('valid_table');
expect(result.inlineDbml).toContain('another_valid_table');
});
it('should handle diagram with only empty tables', () => {
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'empty_table_1',
schema: 'public',
x: 0,
y: 0,
fields: [],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'empty_table_2',
schema: 'public',
x: 0,
y: 0,
fields: [],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateDBMLFromDiagram(diagram);
// Should not error and should return empty DBML (or just enums if any)
expect(result.inlineDbml).toBeTruthy();
expect(result.standardDbml).toBeTruthy();
expect(result.error).toBeUndefined();
});
it('should filter out table that becomes empty after removing invalid fields', () => {
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'table_with_only_empty_field_names',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: '', // Empty field name - will be filtered
type: { id: 'integer', name: 'integer' },
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
{
id: generateId(),
name: '', // Empty field name - will be filtered
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'valid_table',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateDBMLFromDiagram(diagram);
// Table with only empty field names should be filtered out
expect(result.inlineDbml).not.toContain(
'table_with_only_empty_field_names'
);
// Valid table should remain
expect(result.inlineDbml).toContain('valid_table');
});
});

View File

@@ -66,4 +66,12 @@ describe('DBML Export cases', () => {
it('should handle case 5 diagram', { timeout: 30000 }, async () => {
testCase('5');
});
it(
'should handle case 6 diagram - auto increment',
{ timeout: 30000 },
async () => {
testCase('6');
}
);
});

View File

@@ -0,0 +1,248 @@
import { describe, it, expect } from 'vitest';
import { generateDBMLFromDiagram } from '../dbml-export';
import { importDBMLToDiagram } from '../../dbml-import/dbml-import';
import { DatabaseType } from '@/lib/domain/database-type';
import type { Diagram } from '@/lib/domain/diagram';
import { generateId, generateDiagramId } from '@/lib/utils';
describe('DBML Export - Timestamp with Time Zone', () => {
it('should preserve "timestamp with time zone" type through export and reimport', async () => {
// Create a diagram with timestamp with time zone field
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'events',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'created_at',
type: {
id: 'timestamp_with_time_zone',
name: 'timestamp with time zone',
},
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'updated_at',
type: {
id: 'timestamp_without_time_zone',
name: 'timestamp without time zone',
},
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
// Export to DBML
const exportResult = generateDBMLFromDiagram(diagram);
// Verify the DBML contains quoted multi-word types
expect(exportResult.inlineDbml).toContain('"timestamp with time zone"');
expect(exportResult.inlineDbml).toContain(
'"timestamp without time zone"'
);
// Reimport the DBML
const reimportedDiagram = await importDBMLToDiagram(
exportResult.inlineDbml,
{
databaseType: DatabaseType.POSTGRESQL,
}
);
// Verify the types are preserved
const table = reimportedDiagram.tables?.find(
(t) => t.name === 'events'
);
expect(table).toBeDefined();
const createdAtField = table?.fields.find(
(f) => f.name === 'created_at'
);
const updatedAtField = table?.fields.find(
(f) => f.name === 'updated_at'
);
expect(createdAtField?.type.name).toBe('timestamp with time zone');
expect(updatedAtField?.type.name).toBe('timestamp without time zone');
});
it('should handle time with time zone types', async () => {
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'schedules',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'start_time',
type: {
id: 'time_with_time_zone',
name: 'time with time zone',
},
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'end_time',
type: {
id: 'time_without_time_zone',
name: 'time without time zone',
},
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const exportResult = generateDBMLFromDiagram(diagram);
expect(exportResult.inlineDbml).toContain('"time with time zone"');
expect(exportResult.inlineDbml).toContain('"time without time zone"');
const reimportedDiagram = await importDBMLToDiagram(
exportResult.inlineDbml,
{
databaseType: DatabaseType.POSTGRESQL,
}
);
const table = reimportedDiagram.tables?.find(
(t) => t.name === 'schedules'
);
const startTimeField = table?.fields.find(
(f) => f.name === 'start_time'
);
const endTimeField = table?.fields.find((f) => f.name === 'end_time');
expect(startTimeField?.type.name).toBe('time with time zone');
expect(endTimeField?.type.name).toBe('time without time zone');
});
it('should handle double precision type', async () => {
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'measurements',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'value',
type: {
id: 'double_precision',
name: 'double precision',
},
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const exportResult = generateDBMLFromDiagram(diagram);
expect(exportResult.inlineDbml).toContain('"double precision"');
const reimportedDiagram = await importDBMLToDiagram(
exportResult.inlineDbml,
{
databaseType: DatabaseType.POSTGRESQL,
}
);
const table = reimportedDiagram.tables?.find(
(t) => t.name === 'measurements'
);
const valueField = table?.fields.find((f) => f.name === 'value');
expect(valueField?.type.name).toBe('double precision');
});
});

View File

@@ -583,6 +583,54 @@ const fixMultilineTableNames = (dbml: string): string => {
);
};
// Restore increment attribute for auto-incrementing fields
const restoreIncrementAttribute = (dbml: string, tables: DBTable[]): string => {
if (!tables || tables.length === 0) return dbml;
let result = dbml;
tables.forEach((table) => {
// Find fields with increment=true
const incrementFields = table.fields.filter((f) => f.increment);
incrementFields.forEach((field) => {
// Build the table identifier pattern
const tableIdentifier = table.schema
? `"${table.schema.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"\\."${table.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`
: `"${table.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`;
// Escape field name for regex
const escapedFieldName = field.name.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&'
);
// Pattern to match the field line with existing attributes in brackets
// Matches: "field_name" type [existing, attributes]
const fieldPattern = new RegExp(
`(Table ${tableIdentifier} \\{[^}]*?^\\s*"${escapedFieldName}"[^\\[\\n]+)(\\[[^\\]]*\\])`,
'gms'
);
result = result.replace(
fieldPattern,
(match, fieldPart, brackets) => {
// Check if increment already exists
if (brackets.includes('increment')) {
return match;
}
// Add increment to the attributes
const newBrackets = brackets.replace(']', ', increment]');
return fieldPart + newBrackets;
}
);
});
});
return result;
};
// Restore composite primary key names in the DBML
const restoreCompositePKNames = (dbml: string, tables: DBTable[]): string => {
if (!tables || tables.length === 0) return dbml;
@@ -759,31 +807,37 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
};
}) ?? [];
// Remove duplicate tables (consider both schema and table name)
// Filter out empty tables and duplicates in a single pass for performance
const seenTableIdentifiers = new Set<string>();
const uniqueTables = sanitizedTables.filter((table) => {
const tablesWithFields = sanitizedTables.filter((table) => {
// Skip tables with no fields (empty tables cause DBML export to fail)
if (table.fields.length === 0) {
return false;
}
// Create a unique identifier combining schema and table name
const tableIdentifier = table.schema
? `${table.schema}.${table.name}`
: table.name;
// Skip duplicate tables
if (seenTableIdentifiers.has(tableIdentifier)) {
return false; // Skip duplicate
return false;
}
seenTableIdentifiers.add(tableIdentifier);
return true; // Keep unique table
return true; // Keep unique, non-empty table
});
// Create the base filtered diagram structure
const filteredDiagram: Diagram = {
...diagram,
tables: uniqueTables,
tables: tablesWithFields,
relationships:
diagram.relationships?.filter((rel) => {
const sourceTable = uniqueTables.find(
const sourceTable = tablesWithFields.find(
(t) => t.id === rel.sourceTableId
);
const targetTable = uniqueTables.find(
const targetTable = tablesWithFields.find(
(t) => t.id === rel.targetTableId
);
const sourceFieldExists = sourceTable?.fields.some(
@@ -883,10 +937,13 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
);
// Restore schema information that may have been stripped by DBML importer
standard = restoreTableSchemas(standard, uniqueTables);
standard = restoreTableSchemas(standard, tablesWithFields);
// Restore composite primary key names
standard = restoreCompositePKNames(standard, uniqueTables);
standard = restoreCompositePKNames(standard, tablesWithFields);
// Restore increment attribute for auto-incrementing fields
standard = restoreIncrementAttribute(standard, tablesWithFields);
// Prepend Enum DBML to the standard output
if (enumsDBML) {

View File

@@ -342,4 +342,85 @@ describe('DBML Import cases', () => {
);
expect(createdAtField?.default).toBe('now()');
});
it('should handle auto-increment fields correctly', async () => {
const dbmlContent = `Table "public"."table_1" {
"id" integer [pk, not null, increment]
"field_2" bigint [increment]
"field_3" serial [increment]
"field_4" varchar(100) [not null]
}`;
const result = await importDBMLToDiagram(dbmlContent, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(result.tables).toHaveLength(1);
const table = result.tables![0];
expect(table.name).toBe('table_1');
expect(table.fields).toHaveLength(4);
// field with [pk, not null, increment] - should be not null and increment
const idField = table.fields.find((f) => f.name === 'id');
expect(idField?.increment).toBe(true);
expect(idField?.nullable).toBe(false);
expect(idField?.primaryKey).toBe(true);
// field with [increment] only - should be not null and increment
// (auto-increment requires NOT NULL even if not explicitly stated)
const field2 = table.fields.find((f) => f.name === 'field_2');
expect(field2?.increment).toBe(true);
expect(field2?.nullable).toBe(false); // CRITICAL: must be false!
// SERIAL type with [increment] - should be not null and increment
const field3 = table.fields.find((f) => f.name === 'field_3');
expect(field3?.increment).toBe(true);
expect(field3?.nullable).toBe(false);
expect(field3?.type?.name).toBe('serial');
// Regular field with [not null] - should be not null, no increment
const field4 = table.fields.find((f) => f.name === 'field_4');
expect(field4?.increment).toBeUndefined();
expect(field4?.nullable).toBe(false);
});
it('should handle SERIAL types without increment attribute', async () => {
const dbmlContent = `Table "public"."test_table" {
"id" serial [pk]
"counter" bigserial
"small_counter" smallserial
"regular" integer
}`;
const result = await importDBMLToDiagram(dbmlContent, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(result.tables).toHaveLength(1);
const table = result.tables![0];
expect(table.fields).toHaveLength(4);
// SERIAL type without [increment] - should STILL be not null (type requires it)
const idField = table.fields.find((f) => f.name === 'id');
expect(idField?.type?.name).toBe('serial');
expect(idField?.nullable).toBe(false); // CRITICAL: Type requires NOT NULL
expect(idField?.primaryKey).toBe(true);
// BIGSERIAL without [increment] - should be not null
const counterField = table.fields.find((f) => f.name === 'counter');
expect(counterField?.type?.name).toBe('bigserial');
expect(counterField?.nullable).toBe(false); // CRITICAL: Type requires NOT NULL
// SMALLSERIAL without [increment] - should be not null
const smallCounterField = table.fields.find(
(f) => f.name === 'small_counter'
);
expect(smallCounterField?.type?.name).toBe('smallserial');
expect(smallCounterField?.nullable).toBe(false); // CRITICAL: Type requires NOT NULL
// Regular INTEGER - should be nullable by default
const regularField = table.fields.find((f) => f.name === 'regular');
expect(regularField?.type?.name).toBe('integer');
expect(regularField?.nullable).toBe(true); // No NOT NULL constraint
});
});

View File

@@ -5,7 +5,10 @@ import type { DBTable } from '@/lib/domain/db-table';
import type { Cardinality, DBRelationship } from '@/lib/domain/db-relationship';
import type { DBField } from '@/lib/domain/db-field';
import type { DataTypeData } from '@/lib/data/data-types/data-types';
import { findDataTypeDataById } from '@/lib/data/data-types/data-types';
import {
findDataTypeDataById,
requiresNotNull,
} from '@/lib/data/data-types/data-types';
import { defaultTableColor } from '@/lib/colors';
import { DatabaseType } from '@/lib/domain/database-type';
import type Field from '@dbml/core/types/model_structure/field';
@@ -552,7 +555,10 @@ export const importDBMLToDiagram = async (
...options,
enums: extractedData.enums,
}),
nullable: !field.not_null,
nullable:
field.increment || requiresNotNull(field.type.type_name)
? false
: !field.not_null,
primaryKey: field.pk || false,
unique: field.unique || field.pk || false, // Primary keys are always unique
createdAt: Date.now(),

View File

@@ -10,6 +10,8 @@ import { dbTableSchema } from './db-table';
import { areaSchema, type Area } from './area';
import type { DBCustomType } from './db-custom-type';
import { dbCustomTypeSchema } from './db-custom-type';
import type { Note } from './note';
import { noteSchema } from './note';
export interface Diagram {
id: string;
@@ -21,6 +23,7 @@ export interface Diagram {
dependencies?: DBDependency[];
areas?: Area[];
customTypes?: DBCustomType[];
notes?: Note[];
createdAt: Date;
updatedAt: Date;
}
@@ -35,6 +38,7 @@ export const diagramSchema: z.ZodType<Diagram> = z.object({
dependencies: z.array(dbDependencySchema).optional(),
areas: z.array(areaSchema).optional(),
customTypes: z.array(dbCustomTypeSchema).optional(),
notes: z.array(noteSchema).optional(),
createdAt: z.date(),
updatedAt: z.date(),
});

View File

@@ -6,10 +6,12 @@ import type { DBField } from '@/lib/domain/db-field';
import type { DBIndex } from '@/lib/domain/db-index';
import type { DBRelationship } from '@/lib/domain/db-relationship';
import type { Area } from '@/lib/domain/area';
import type { Note } from '@/lib/domain/note';
import { DatabaseType } from '@/lib/domain/database-type';
import type { TableDiffChanged } from '../../table-diff';
import type { FieldDiffChanged } from '../../field-diff';
import type { AreaDiffChanged } from '../../area-diff';
import type { NoteDiffChanged } from '../../note-diff';
// Helper function to create a mock diagram
function createMockDiagram(overrides?: Partial<Diagram>): Diagram {
@@ -81,6 +83,20 @@ function createMockArea(overrides?: Partial<Area>): Area {
} as Area;
}
// Helper function to create a mock note
function createMockNote(overrides?: Partial<Note>): Note {
return {
id: 'note-1',
content: 'Test note content',
x: 0,
y: 0,
width: 200,
height: 150,
color: '#3b82f6',
...overrides,
} as Note;
}
describe('generateDiff', () => {
describe('Basic Table Diffing', () => {
it('should detect added tables', () => {
@@ -466,6 +482,408 @@ describe('generateDiff', () => {
});
});
describe('Note Diffing', () => {
it('should detect added notes when includeNotes is true', () => {
const oldDiagram = createMockDiagram({ notes: [] });
const newDiagram = createMockDiagram({
notes: [createMockNote()],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeNotes: true,
},
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('note-note-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('added');
expect(result.changedNotes.has('note-1')).toBe(true);
});
it('should not detect note changes when includeNotes is false', () => {
const oldDiagram = createMockDiagram({ notes: [] });
const newDiagram = createMockDiagram({
notes: [createMockNote()],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeNotes: false,
},
});
expect(result.diffMap.size).toBe(0);
expect(result.changedNotes.size).toBe(0);
});
it('should detect removed notes', () => {
const oldDiagram = createMockDiagram({
notes: [createMockNote()],
});
const newDiagram = createMockDiagram({ notes: [] });
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeNotes: true,
},
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('note-note-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('removed');
expect(result.changedNotes.has('note-1')).toBe(true);
});
it('should detect note content changes', () => {
const oldDiagram = createMockDiagram({
notes: [createMockNote({ content: 'Old content' })],
});
const newDiagram = createMockDiagram({
notes: [createMockNote({ content: 'New content' })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeNotes: true,
},
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('note-content-note-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('changed');
expect((diff as NoteDiffChanged)?.attribute).toBe('content');
expect((diff as NoteDiffChanged)?.oldValue).toBe('Old content');
expect((diff as NoteDiffChanged)?.newValue).toBe('New content');
});
it('should detect note color changes', () => {
const oldDiagram = createMockDiagram({
notes: [createMockNote({ color: '#3b82f6' })],
});
const newDiagram = createMockDiagram({
notes: [createMockNote({ color: '#ef4444' })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeNotes: true,
},
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('note-color-note-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('changed');
expect((diff as NoteDiffChanged)?.attribute).toBe('color');
expect((diff as NoteDiffChanged)?.oldValue).toBe('#3b82f6');
expect((diff as NoteDiffChanged)?.newValue).toBe('#ef4444');
});
it('should detect note position changes', () => {
const oldDiagram = createMockDiagram({
notes: [createMockNote({ x: 0, y: 0 })],
});
const newDiagram = createMockDiagram({
notes: [createMockNote({ x: 100, y: 200 })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeNotes: true,
attributes: {
notes: ['content', 'color', 'x', 'y'],
},
},
});
expect(result.diffMap.size).toBe(2);
expect(result.diffMap.has('note-x-note-1')).toBe(true);
expect(result.diffMap.has('note-y-note-1')).toBe(true);
const xDiff = result.diffMap.get('note-x-note-1');
expect((xDiff as NoteDiffChanged)?.oldValue).toBe(0);
expect((xDiff as NoteDiffChanged)?.newValue).toBe(100);
});
it('should detect note width changes', () => {
const oldDiagram = createMockDiagram({
notes: [createMockNote({ width: 200 })],
});
const newDiagram = createMockDiagram({
notes: [createMockNote({ width: 300 })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeNotes: true,
attributes: {
notes: ['width'],
},
},
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('note-width-note-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('changed');
expect((diff as NoteDiffChanged)?.attribute).toBe('width');
expect((diff as NoteDiffChanged)?.oldValue).toBe(200);
expect((diff as NoteDiffChanged)?.newValue).toBe(300);
});
it('should detect note height changes', () => {
const oldDiagram = createMockDiagram({
notes: [createMockNote({ height: 150 })],
});
const newDiagram = createMockDiagram({
notes: [createMockNote({ height: 250 })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeNotes: true,
attributes: {
notes: ['height'],
},
},
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('note-height-note-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('changed');
expect((diff as NoteDiffChanged)?.attribute).toBe('height');
expect((diff as NoteDiffChanged)?.oldValue).toBe(150);
expect((diff as NoteDiffChanged)?.newValue).toBe(250);
});
it('should detect multiple note dimension changes', () => {
const oldDiagram = createMockDiagram({
notes: [
createMockNote({ x: 0, y: 0, width: 200, height: 150 }),
],
});
const newDiagram = createMockDiagram({
notes: [
createMockNote({ x: 50, y: 75, width: 300, height: 250 }),
],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeNotes: true,
attributes: {
notes: ['x', 'y', 'width', 'height'],
},
},
});
expect(result.diffMap.size).toBe(4);
expect(result.diffMap.has('note-x-note-1')).toBe(true);
expect(result.diffMap.has('note-y-note-1')).toBe(true);
expect(result.diffMap.has('note-width-note-1')).toBe(true);
expect(result.diffMap.has('note-height-note-1')).toBe(true);
const widthDiff = result.diffMap.get('note-width-note-1');
expect((widthDiff as NoteDiffChanged)?.oldValue).toBe(200);
expect((widthDiff as NoteDiffChanged)?.newValue).toBe(300);
const heightDiff = result.diffMap.get('note-height-note-1');
expect((heightDiff as NoteDiffChanged)?.oldValue).toBe(150);
expect((heightDiff as NoteDiffChanged)?.newValue).toBe(250);
});
it('should detect multiple notes with different changes', () => {
const oldDiagram = createMockDiagram({
notes: [
createMockNote({ id: 'note-1', content: 'Note 1' }),
createMockNote({ id: 'note-2', content: 'Note 2' }),
createMockNote({ id: 'note-3', content: 'Note 3' }),
],
});
const newDiagram = createMockDiagram({
notes: [
createMockNote({
id: 'note-1',
content: 'Note 1 Updated',
}), // Changed
createMockNote({ id: 'note-2', content: 'Note 2' }), // Unchanged
// note-3 removed
createMockNote({ id: 'note-4', content: 'Note 4' }), // Added
],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeNotes: true,
},
});
// Should detect: 1 content change, 1 removal, 1 addition
expect(result.diffMap.has('note-content-note-1')).toBe(true); // Changed
expect(result.diffMap.has('note-note-3')).toBe(true); // Removed
expect(result.diffMap.has('note-note-4')).toBe(true); // Added
expect(result.changedNotes.has('note-1')).toBe(true);
expect(result.changedNotes.has('note-3')).toBe(true);
expect(result.changedNotes.has('note-4')).toBe(true);
});
it('should use custom note matcher', () => {
const oldDiagram = createMockDiagram({
notes: [
createMockNote({
id: 'note-1',
content: 'Unique content',
color: '#3b82f6',
}),
],
});
const newDiagram = createMockDiagram({
notes: [
createMockNote({
id: 'note-2',
content: 'Unique content',
color: '#ef4444',
}),
],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeNotes: true,
matchers: {
note: (note, notes) =>
notes.find((n) => n.content === note.content),
},
},
});
// With content-based matching, note-1 should match note-2 by content
// and detect the color change
const colorChange = result.diffMap.get('note-color-note-1');
expect(colorChange).toBeDefined();
expect(colorChange?.type).toBe('changed');
expect((colorChange as NoteDiffChanged)?.attribute).toBe('color');
expect((colorChange as NoteDiffChanged)?.oldValue).toBe('#3b82f6');
expect((colorChange as NoteDiffChanged)?.newValue).toBe('#ef4444');
});
it('should only check specified note change types', () => {
const oldDiagram = createMockDiagram({
notes: [createMockNote({ id: 'note-1', content: 'Note 1' })],
});
const newDiagram = createMockDiagram({
notes: [createMockNote({ id: 'note-2', content: 'Note 2' })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeNotes: true,
changeTypes: {
notes: ['added'], // Only check for added notes
},
},
});
// Should only detect added note (note-2)
const addedNotes = Array.from(result.diffMap.values()).filter(
(diff) => diff.type === 'added' && diff.object === 'note'
);
expect(addedNotes.length).toBe(1);
// Should not detect removed note (note-1)
const removedNotes = Array.from(result.diffMap.values()).filter(
(diff) => diff.type === 'removed' && diff.object === 'note'
);
expect(removedNotes.length).toBe(0);
});
it('should only check specified note attributes', () => {
const oldDiagram = createMockDiagram({
notes: [
createMockNote({
id: 'note-1',
content: 'Old content',
color: '#3b82f6',
x: 0,
y: 0,
}),
],
});
const newDiagram = createMockDiagram({
notes: [
createMockNote({
id: 'note-1',
content: 'New content',
color: '#ef4444',
x: 100,
y: 200,
}),
],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeNotes: true,
attributes: {
notes: ['content'], // Only check content changes
},
},
});
// Should only detect content change
const contentChanges = Array.from(result.diffMap.values()).filter(
(diff) =>
diff.type === 'changed' &&
diff.attribute === 'content' &&
diff.object === 'note'
);
expect(contentChanges.length).toBe(1);
// Should not detect color or position changes
const otherChanges = Array.from(result.diffMap.values()).filter(
(diff) =>
diff.type === 'changed' &&
(diff.attribute === 'color' ||
diff.attribute === 'x' ||
diff.attribute === 'y') &&
diff.object === 'note'
);
expect(otherChanges.length).toBe(0);
});
});
describe('Custom Matchers', () => {
it('should use custom table matcher to match by name', () => {
const oldDiagram = createMockDiagram({
@@ -708,7 +1126,7 @@ describe('generateDiff', () => {
});
describe('Complex Scenarios', () => {
it('should detect all dimensional changes for tables and areas', () => {
it('should detect all dimensional changes for tables, areas, and notes', () => {
const oldDiagram = createMockDiagram({
tables: [
createMockTable({
@@ -727,6 +1145,15 @@ describe('generateDiff', () => {
height: 150,
}),
],
notes: [
createMockNote({
id: 'note-1',
x: 0,
y: 0,
width: 300,
height: 200,
}),
],
});
const newDiagram = createMockDiagram({
@@ -747,6 +1174,15 @@ describe('generateDiff', () => {
height: 175,
}),
],
notes: [
createMockNote({
id: 'note-1',
x: 40,
y: 50,
width: 350,
height: 225,
}),
],
});
const result = generateDiff({
@@ -754,9 +1190,11 @@ describe('generateDiff', () => {
newDiagram,
options: {
includeAreas: true,
includeNotes: true,
attributes: {
tables: ['x', 'y', 'width'],
areas: ['x', 'y', 'width', 'height'],
notes: ['x', 'y', 'width', 'height'],
},
},
});
@@ -772,6 +1210,12 @@ describe('generateDiff', () => {
expect(result.diffMap.has('area-width-area-1')).toBe(true);
expect(result.diffMap.has('area-height-area-1')).toBe(true);
// Note dimensional changes
expect(result.diffMap.has('note-x-note-1')).toBe(true);
expect(result.diffMap.has('note-y-note-1')).toBe(true);
expect(result.diffMap.has('note-width-note-1')).toBe(true);
expect(result.diffMap.has('note-height-note-1')).toBe(true);
// Verify the correct values
const tableWidthDiff = result.diffMap.get('table-width-table-1');
expect((tableWidthDiff as TableDiffChanged)?.oldValue).toBe(100);
@@ -784,6 +1228,14 @@ describe('generateDiff', () => {
const areaHeightDiff = result.diffMap.get('area-height-area-1');
expect((areaHeightDiff as AreaDiffChanged)?.oldValue).toBe(150);
expect((areaHeightDiff as AreaDiffChanged)?.newValue).toBe(175);
const noteWidthDiff = result.diffMap.get('note-width-note-1');
expect((noteWidthDiff as NoteDiffChanged)?.oldValue).toBe(300);
expect((noteWidthDiff as NoteDiffChanged)?.newValue).toBe(350);
const noteHeightDiff = result.diffMap.get('note-height-note-1');
expect((noteHeightDiff as NoteDiffChanged)?.oldValue).toBe(200);
expect((noteHeightDiff as NoteDiffChanged)?.newValue).toBe(225);
});
it('should handle multiple simultaneous changes', () => {
@@ -852,6 +1304,7 @@ describe('generateDiff', () => {
expect(result.changedTables.size).toBe(0);
expect(result.changedFields.size).toBe(0);
expect(result.changedAreas.size).toBe(0);
expect(result.changedNotes.size).toBe(0);
});
it('should handle diagrams with undefined collections', () => {
@@ -859,11 +1312,13 @@ describe('generateDiff', () => {
tables: undefined,
relationships: undefined,
areas: undefined,
notes: undefined,
});
const diagram2 = createMockDiagram({
tables: [createMockTable({ id: 'table-1' })],
relationships: [createMockRelationship({ id: 'rel-1' })],
areas: [createMockArea({ id: 'area-1' })],
notes: [createMockNote({ id: 'note-1' })],
});
const result = generateDiff({
@@ -871,6 +1326,7 @@ describe('generateDiff', () => {
newDiagram: diagram2,
options: {
includeAreas: true,
includeNotes: true,
},
});
@@ -878,6 +1334,7 @@ describe('generateDiff', () => {
expect(result.diffMap.has('table-table-1')).toBe(true);
expect(result.diffMap.has('relationship-rel-1')).toBe(true);
expect(result.diffMap.has('area-area-1')).toBe(true);
expect(result.diffMap.has('note-note-1')).toBe(true);
});
});
});

View File

@@ -4,6 +4,7 @@ import type { DBIndex } from '@/lib/domain/db-index';
import type { DBTable } from '@/lib/domain/db-table';
import type { DBRelationship } from '@/lib/domain/db-relationship';
import type { Area } from '@/lib/domain/area';
import type { Note } from '@/lib/domain/note';
import type { ChartDBDiff, DiffMap, DiffObject } from '@/lib/domain/diff/diff';
import type {
FieldDiff,
@@ -11,6 +12,7 @@ import type {
} from '@/lib/domain/diff/field-diff';
import type { TableDiff, TableDiffAttribute } from '../table-diff';
import type { AreaDiff, AreaDiffAttribute } from '../area-diff';
import type { NoteDiff, NoteDiffAttribute } from '../note-diff';
import type { IndexDiff } from '../index-diff';
import type { RelationshipDiff } from '../relationship-diff';
@@ -44,10 +46,12 @@ export interface GenerateDiffOptions {
includeIndexes?: boolean;
includeRelationships?: boolean;
includeAreas?: boolean;
includeNotes?: boolean;
attributes?: {
tables?: TableDiffAttribute[];
fields?: FieldDiffAttribute[];
areas?: AreaDiffAttribute[];
notes?: NoteDiffAttribute[];
};
changeTypes?: {
tables?: TableDiff['type'][];
@@ -55,6 +59,7 @@ export interface GenerateDiffOptions {
indexes?: IndexDiff['type'][];
relationships?: RelationshipDiff['type'][];
areas?: AreaDiff['type'][];
notes?: NoteDiff['type'][];
};
matchers?: {
table?: (table: DBTable, tables: DBTable[]) => DBTable | undefined;
@@ -65,6 +70,7 @@ export interface GenerateDiffOptions {
relationships: DBRelationship[]
) => DBRelationship | undefined;
area?: (area: Area, areas: Area[]) => Area | undefined;
note?: (note: Note, notes: Note[]) => Note | undefined;
};
}
@@ -81,6 +87,7 @@ export function generateDiff({
changedTables: Map<string, boolean>;
changedFields: Map<string, boolean>;
changedAreas: Map<string, boolean>;
changedNotes: Map<string, boolean>;
} {
// Merge with default options
const mergedOptions: GenerateDiffOptions = {
@@ -89,6 +96,7 @@ export function generateDiff({
includeIndexes: options.includeIndexes ?? true,
includeRelationships: options.includeRelationships ?? true,
includeAreas: options.includeAreas ?? false,
includeNotes: options.includeNotes ?? false,
attributes: options.attributes ?? {},
changeTypes: options.changeTypes ?? {},
matchers: options.matchers ?? {},
@@ -98,6 +106,7 @@ export function generateDiff({
const changedTables = new Map<string, boolean>();
const changedFields = new Map<string, boolean>();
const changedAreas = new Map<string, boolean>();
const changedNotes = new Map<string, boolean>();
// Use provided matchers or default ones
const tableMatcher = mergedOptions.matchers?.table ?? defaultTableMatcher;
@@ -106,6 +115,7 @@ export function generateDiff({
const relationshipMatcher =
mergedOptions.matchers?.relationship ?? defaultRelationshipMatcher;
const areaMatcher = mergedOptions.matchers?.area ?? defaultAreaMatcher;
const noteMatcher = mergedOptions.matchers?.note ?? defaultNoteMatcher;
// Compare tables
if (mergedOptions.includeTables) {
@@ -157,7 +167,26 @@ export function generateDiff({
});
}
return { diffMap: newDiffs, changedTables, changedFields, changedAreas };
// Compare notes if enabled
if (mergedOptions.includeNotes) {
compareNotes({
diagram,
newDiagram,
diffMap: newDiffs,
changedNotes,
attributes: mergedOptions.attributes?.notes,
changeTypes: mergedOptions.changeTypes?.notes,
noteMatcher,
});
}
return {
diffMap: newDiffs,
changedTables,
changedFields,
changedAreas,
changedNotes,
};
}
// Compare tables between diagrams
@@ -1019,6 +1048,217 @@ function compareAreas({
}
}
// Compare notes between diagrams
function compareNotes({
diagram,
newDiagram,
diffMap,
changedNotes,
attributes,
changeTypes,
noteMatcher,
}: {
diagram: Diagram;
newDiagram: Diagram;
diffMap: DiffMap;
changedNotes: Map<string, boolean>;
attributes?: NoteDiffAttribute[];
changeTypes?: NoteDiff['type'][];
noteMatcher: (note: Note, notes: Note[]) => Note | undefined;
}) {
const oldNotes = diagram.notes || [];
const newNotes = newDiagram.notes || [];
// If changeTypes is empty array, don't check any changes
if (changeTypes && changeTypes.length === 0) {
return;
}
// If changeTypes is undefined, check all types
const typesToCheck = changeTypes ?? ['added', 'removed', 'changed'];
// Check for added notes
if (typesToCheck.includes('added')) {
for (const newNote of newNotes) {
if (!noteMatcher(newNote, oldNotes)) {
diffMap.set(
getDiffMapKey({
diffObject: 'note',
objectId: newNote.id,
}),
{
object: 'note',
type: 'added',
noteAdded: newNote,
}
);
changedNotes.set(newNote.id, true);
}
}
}
// Check for removed notes
if (typesToCheck.includes('removed')) {
for (const oldNote of oldNotes) {
if (!noteMatcher(oldNote, newNotes)) {
diffMap.set(
getDiffMapKey({
diffObject: 'note',
objectId: oldNote.id,
}),
{
object: 'note',
type: 'removed',
noteId: oldNote.id,
}
);
changedNotes.set(oldNote.id, true);
}
}
}
// Check for note content and color changes
if (typesToCheck.includes('changed')) {
for (const oldNote of oldNotes) {
const newNote = noteMatcher(oldNote, newNotes);
if (!newNote) continue;
// If attributes are specified, only check those attributes
const attributesToCheck: NoteDiffAttribute[] = attributes ?? [
'content',
'color',
];
if (
attributesToCheck.includes('content') &&
oldNote.content !== newNote.content
) {
diffMap.set(
getDiffMapKey({
diffObject: 'note',
objectId: oldNote.id,
attribute: 'content',
}),
{
object: 'note',
type: 'changed',
noteId: oldNote.id,
attribute: 'content',
newValue: newNote.content,
oldValue: oldNote.content,
}
);
changedNotes.set(oldNote.id, true);
}
if (
attributesToCheck.includes('color') &&
oldNote.color !== newNote.color
) {
diffMap.set(
getDiffMapKey({
diffObject: 'note',
objectId: oldNote.id,
attribute: 'color',
}),
{
object: 'note',
type: 'changed',
noteId: oldNote.id,
attribute: 'color',
newValue: newNote.color,
oldValue: oldNote.color,
}
);
changedNotes.set(oldNote.id, true);
}
if (attributesToCheck.includes('x') && oldNote.x !== newNote.x) {
diffMap.set(
getDiffMapKey({
diffObject: 'note',
objectId: oldNote.id,
attribute: 'x',
}),
{
object: 'note',
type: 'changed',
noteId: oldNote.id,
attribute: 'x',
newValue: newNote.x,
oldValue: oldNote.x,
}
);
changedNotes.set(oldNote.id, true);
}
if (attributesToCheck.includes('y') && oldNote.y !== newNote.y) {
diffMap.set(
getDiffMapKey({
diffObject: 'note',
objectId: oldNote.id,
attribute: 'y',
}),
{
object: 'note',
type: 'changed',
noteId: oldNote.id,
attribute: 'y',
newValue: newNote.y,
oldValue: oldNote.y,
}
);
changedNotes.set(oldNote.id, true);
}
if (
attributesToCheck.includes('width') &&
oldNote.width !== newNote.width
) {
diffMap.set(
getDiffMapKey({
diffObject: 'note',
objectId: oldNote.id,
attribute: 'width',
}),
{
object: 'note',
type: 'changed',
noteId: oldNote.id,
attribute: 'width',
newValue: newNote.width,
oldValue: oldNote.width,
}
);
changedNotes.set(oldNote.id, true);
}
if (
attributesToCheck.includes('height') &&
oldNote.height !== newNote.height
) {
diffMap.set(
getDiffMapKey({
diffObject: 'note',
objectId: oldNote.id,
attribute: 'height',
}),
{
object: 'note',
type: 'changed',
noteId: oldNote.id,
attribute: 'height',
newValue: newNote.height,
oldValue: oldNote.height,
}
);
changedNotes.set(oldNote.id, true);
}
}
}
}
const defaultTableMatcher = (
table: DBTable,
tables: DBTable[]
@@ -1050,3 +1290,7 @@ const defaultRelationshipMatcher = (
const defaultAreaMatcher = (area: Area, areas: Area[]): Area | undefined => {
return areas.find((a) => a.id === area.id);
};
const defaultNoteMatcher = (note: Note, notes: Note[]): Note | undefined => {
return notes.find((n) => n.id === note.id);
};

View File

@@ -10,7 +10,9 @@ import type { TableDiff } from './table-diff';
import { createTableDiffSchema } from './table-diff';
import type { AreaDiff } from './area-diff';
import { createAreaDiffSchema } from './area-diff';
import type { DBField, DBIndex, DBRelationship, DBTable, Area } from '..';
import type { NoteDiff } from './note-diff';
import { createNoteDiffSchema } from './note-diff';
import type { DBField, DBIndex, DBRelationship, DBTable, Area, Note } from '..';
export type ChartDBDiff<
TTable = DBTable,
@@ -18,12 +20,14 @@ export type ChartDBDiff<
TIndex = DBIndex,
TRelationship = DBRelationship,
TArea = Area,
TNote = Note,
> =
| TableDiff<TTable>
| FieldDiff<TField>
| IndexDiff<TIndex>
| RelationshipDiff<TRelationship>
| AreaDiff<TArea>;
| AreaDiff<TArea>
| NoteDiff<TNote>;
export const createChartDBDiffSchema = <
TTable = DBTable,
@@ -31,20 +35,27 @@ export const createChartDBDiffSchema = <
TIndex = DBIndex,
TRelationship = DBRelationship,
TArea = Area,
TNote = Note,
>(
tableSchema: z.ZodType<TTable>,
fieldSchema: z.ZodType<TField>,
indexSchema: z.ZodType<TIndex>,
relationshipSchema: z.ZodType<TRelationship>,
areaSchema: z.ZodType<TArea>
): z.ZodType<ChartDBDiff<TTable, TField, TIndex, TRelationship, TArea>> => {
areaSchema: z.ZodType<TArea>,
noteSchema: z.ZodType<TNote>
): z.ZodType<
ChartDBDiff<TTable, TField, TIndex, TRelationship, TArea, TNote>
> => {
return z.union([
createTableDiffSchema(tableSchema),
createFieldDiffSchema(fieldSchema),
createIndexDiffSchema(indexSchema),
createRelationshipDiffSchema(relationshipSchema),
createAreaDiffSchema(areaSchema),
]) as z.ZodType<ChartDBDiff<TTable, TField, TIndex, TRelationship, TArea>>;
createNoteDiffSchema(noteSchema),
]) as z.ZodType<
ChartDBDiff<TTable, TField, TIndex, TRelationship, TArea, TNote>
>;
};
export type DiffMap<
@@ -53,7 +64,11 @@ export type DiffMap<
TIndex = DBIndex,
TRelationship = DBRelationship,
TArea = Area,
> = Map<string, ChartDBDiff<TTable, TField, TIndex, TRelationship, TArea>>;
TNote = Note,
> = Map<
string,
ChartDBDiff<TTable, TField, TIndex, TRelationship, TArea, TNote>
>;
export type DiffObject<
TTable = DBTable,
@@ -61,12 +76,14 @@ export type DiffObject<
TIndex = DBIndex,
TRelationship = DBRelationship,
TArea = Area,
TNote = Note,
> =
| TableDiff<TTable>['object']
| FieldDiff<TField>['object']
| IndexDiff<TIndex>['object']
| RelationshipDiff<TRelationship>['object']
| AreaDiff<TArea>['object'];
| AreaDiff<TArea>['object']
| NoteDiff<TNote>['object'];
type ExtractDiffKind<T> = T extends { object: infer O; type: infer Type }
? T extends { attribute: infer A }
@@ -80,7 +97,10 @@ export type DiffKind<
TIndex = DBIndex,
TRelationship = DBRelationship,
TArea = Area,
> = ExtractDiffKind<ChartDBDiff<TTable, TField, TIndex, TRelationship, TArea>>;
TNote = Note,
> = ExtractDiffKind<
ChartDBDiff<TTable, TField, TIndex, TRelationship, TArea, TNote>
>;
export const isDiffOfKind = <
TTable = DBTable,
@@ -88,9 +108,10 @@ export const isDiffOfKind = <
TIndex = DBIndex,
TRelationship = DBRelationship,
TArea = Area,
TNote = Note,
>(
diff: ChartDBDiff<TTable, TField, TIndex, TRelationship, TArea>,
kind: DiffKind<TTable, TField, TIndex, TRelationship, TArea>
diff: ChartDBDiff<TTable, TField, TIndex, TRelationship, TArea, TNote>,
kind: DiffKind<TTable, TField, TIndex, TRelationship, TArea, TNote>
): boolean => {
if ('attribute' in kind) {
return (

View File

@@ -0,0 +1,77 @@
import { z } from 'zod';
import type { Note } from '../note';
export type NoteDiffAttribute = keyof Pick<
Note,
'content' | 'color' | 'x' | 'y' | 'width' | 'height'
>;
const noteDiffAttributeSchema: z.ZodType<NoteDiffAttribute> = z.union([
z.literal('content'),
z.literal('color'),
z.literal('x'),
z.literal('y'),
z.literal('width'),
z.literal('height'),
]);
export interface NoteDiffChanged {
object: 'note';
type: 'changed';
noteId: string;
attribute: NoteDiffAttribute;
oldValue?: string | number | null;
newValue?: string | number | null;
}
export const NoteDiffChangedSchema: z.ZodType<NoteDiffChanged> = z.object({
object: z.literal('note'),
type: z.literal('changed'),
noteId: z.string(),
attribute: noteDiffAttributeSchema,
oldValue: z.union([z.string(), z.number(), z.null()]).optional(),
newValue: z.union([z.string(), z.number(), z.null()]).optional(),
});
export interface NoteDiffRemoved {
object: 'note';
type: 'removed';
noteId: string;
}
export const NoteDiffRemovedSchema: z.ZodType<NoteDiffRemoved> = z.object({
object: z.literal('note'),
type: z.literal('removed'),
noteId: z.string(),
});
export interface NoteDiffAdded<T = Note> {
object: 'note';
type: 'added';
noteAdded: T;
}
export const createNoteDiffAddedSchema = <T = Note>(
noteSchema: z.ZodType<T>
): z.ZodType<NoteDiffAdded<T>> => {
return z.object({
object: z.literal('note'),
type: z.literal('added'),
noteAdded: noteSchema,
}) as z.ZodType<NoteDiffAdded<T>>;
};
export type NoteDiff<T = Note> =
| NoteDiffChanged
| NoteDiffRemoved
| NoteDiffAdded<T>;
export const createNoteDiffSchema = <T = Note>(
noteSchema: z.ZodType<T>
): z.ZodType<NoteDiff<T>> => {
return z.union([
NoteDiffChangedSchema,
NoteDiffRemovedSchema,
createNoteDiffAddedSchema(noteSchema),
]) as z.ZodType<NoteDiff<T>>;
};

View File

@@ -11,3 +11,4 @@ export * from './db-relationship';
export * from './db-schema';
export * from './db-table';
export * from './diagram';
export * from './note';

23
src/lib/domain/note.ts Normal file
View File

@@ -0,0 +1,23 @@
import { z } from 'zod';
export interface Note {
id: string;
content: string;
x: number;
y: number;
width: number;
height: number;
color: string;
order?: number;
}
export const noteSchema: z.ZodType<Note> = z.object({
id: z.string(),
content: z.string(),
x: z.number(),
y: z.number(),
width: z.number(),
height: z.number(),
color: z.string(),
order: z.number().optional(),
});

View File

@@ -32,7 +32,8 @@ export const AreaNode: React.FC<NodeProps<AreaNodeType>> = React.memo(
const [editMode, setEditMode] = useState(false);
const [areaName, setAreaName] = useState(area.name);
const inputRef = React.useRef<HTMLInputElement>(null);
const { openAreaFromSidebar, selectSidebarSection } = useLayout();
const { openAreaFromSidebar, selectSidebarSection, selectVisualsTab } =
useLayout();
const focused = !!selected && !dragging;
@@ -50,9 +51,15 @@ export const AreaNode: React.FC<NodeProps<AreaNodeType>> = React.memo(
}, [area.name]);
const openAreaInEditor = useCallback(() => {
selectSidebarSection('areas');
selectSidebarSection('visuals');
selectVisualsTab('areas');
openAreaFromSidebar(area.id);
}, [selectSidebarSection, openAreaFromSidebar, area.id]);
}, [
selectSidebarSection,
openAreaFromSidebar,
area.id,
selectVisualsTab,
]);
useClickAway(inputRef, editAreaName);
useKeyPressEvent('Enter', editAreaName);

View File

@@ -2,6 +2,7 @@ import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/context-menu/context-menu';
import { useBreakpoint } from '@/hooks/use-breakpoint';
@@ -10,7 +11,7 @@ import { useDialog } from '@/hooks/use-dialog';
import { useReactFlow } from '@xyflow/react';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Table, Workflow, Group, View } from 'lucide-react';
import { Table, Workflow, Group, View, StickyNote } from 'lucide-react';
import { useDiagramFilter } from '@/context/diagram-filter-context/use-diagram-filter';
import { useLocalConfig } from '@/hooks/use-local-config';
import { useCanvas } from '@/hooks/use-canvas';
@@ -19,7 +20,8 @@ import { defaultSchemas } from '@/lib/data/default-schemas';
export const CanvasContextMenu: React.FC<React.PropsWithChildren> = ({
children,
}) => {
const { createTable, readonly, createArea, databaseType } = useChartDB();
const { createTable, readonly, createArea, databaseType, createNote } =
useChartDB();
const { schemasDisplayed } = useDiagramFilter();
const { openCreateRelationshipDialog } = useDialog();
const { screenToFlowPosition } = useReactFlow();
@@ -121,6 +123,21 @@ export const CanvasContextMenu: React.FC<React.PropsWithChildren> = ({
[createArea, screenToFlowPosition]
);
const createNoteHandler = useCallback(
(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
createNote({
x: position.x,
y: position.y,
});
},
[createNote, screenToFlowPosition]
);
const createRelationshipHandler = useCallback(() => {
openCreateRelationshipDialog();
}, [openCreateRelationshipDialog]);
@@ -158,6 +175,7 @@ export const CanvasContextMenu: React.FC<React.PropsWithChildren> = ({
{t('canvas_context_menu.new_relationship')}
<Workflow className="size-3.5" />
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={createAreaHandler}
className="flex justify-between gap-4"
@@ -165,6 +183,13 @@ export const CanvasContextMenu: React.FC<React.PropsWithChildren> = ({
{t('canvas_context_menu.new_area')}
<Group className="size-3.5" />
</ContextMenuItem>
<ContextMenuItem
onClick={createNoteHandler}
className="flex justify-between gap-4"
>
{t('canvas_context_menu.new_note')}
<StickyNote className="size-3.5" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);

View File

@@ -101,13 +101,32 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
areas,
]);
// Initialize expanded state with all schemas expanded
useMemo(() => {
const initialExpanded: Record<string, boolean> = {};
treeData.forEach((node) => {
initialExpanded[node.id] = true;
// Sync expanded state with tree data changes - only expand NEW nodes
useEffect(() => {
setExpanded((prev) => {
const currentNodeIds = new Set(treeData.map((n) => n.id));
let hasChanges = false;
const newExpanded: Record<string, boolean> = { ...prev };
// Add any new nodes with expanded=true (preserve existing state)
treeData.forEach((node) => {
if (!(node.id in prev)) {
newExpanded[node.id] = true;
hasChanges = true;
}
});
// Remove nodes that no longer exist (cleanup)
Object.keys(prev).forEach((id) => {
if (!currentNodeIds.has(id)) {
delete newExpanded[id];
hasChanges = true;
}
});
// Only update state if something actually changed (performance)
return hasChanges ? newExpanded : prev;
});
setExpanded(initialExpanded);
}, [treeData]);
// Filter tree data based on search query
@@ -317,6 +336,7 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
expanded={expanded}
setExpanded={setExpanded}
className="py-2"
disableCache={true}
/>
</ScrollArea>
</div>

View File

@@ -83,6 +83,9 @@ import { useCanvas } from '@/hooks/use-canvas';
import type { AreaNodeType } from './area-node/area-node';
import { AreaNode } from './area-node/area-node';
import type { Area } from '@/lib/domain/area';
import type { NoteNodeType } from './note-node/note-node';
import { NoteNode } from './note-node/note-node';
import type { Note } from '@/lib/domain/note';
import type { TempCursorNodeType } from './temp-cursor-node/temp-cursor-node';
import {
TEMP_CURSOR_HANDLE_ID,
@@ -123,6 +126,7 @@ export type EdgeType =
export type NodeType =
| TableNodeType
| AreaNodeType
| NoteNodeType
| TempCursorNodeType
| CreateRelationshipNodeType;
@@ -137,6 +141,7 @@ const edgeTypes: EdgeTypes = {
const nodeTypes: NodeTypes = {
table: TableNode,
area: AreaNode,
note: NoteNode,
'temp-cursor': TempCursorNode,
'create-relationship': CreateRelationshipNode,
};
@@ -238,6 +243,21 @@ const areaToAreaNode = (
};
};
const noteToNoteNode = (note: Note): NoteNodeType => {
return {
id: note.id,
type: 'note',
position: { x: note.x, y: note.y },
data: { note },
width: note.width,
height: note.height,
zIndex: 50,
style: {
zIndex: 50,
},
};
};
export interface CanvasProps {
initialTables: DBTable[];
}
@@ -254,6 +274,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
const {
tables,
areas,
notes,
relationships,
createRelationship,
createDependency,
@@ -267,6 +288,8 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
readonly,
removeArea,
updateArea,
removeNote,
updateNote,
highlightedCustomType,
highlightCustomTypeId,
} = useChartDB();
@@ -287,6 +310,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
endFloatingEdgeCreation,
hoveringTableId,
hideCreateRelationshipNode,
events: canvasEvents,
} = useCanvas();
const { filter, loading: filterLoading } = useDiagramFilter();
const { checkIfNewTable } = useDiff();
@@ -543,6 +567,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
filterLoading,
})
),
...notes.map((note) => noteToNoteNode(note)),
...prevNodes.filter(
(n) =>
n.type === 'temp-cursor' ||
@@ -560,6 +585,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
}, [
tables,
areas,
notes,
setNodes,
filter,
databaseType,
@@ -975,6 +1001,13 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
sizeChanges: areaSizeChanges,
} = findRelevantNodesChanges(changesToApply, 'area');
// Then, detect note changes
const {
positionChanges: notePositionChanges,
removeChanges: noteRemoveChanges,
sizeChanges: noteSizeChanges,
} = findRelevantNodesChanges(changesToApply, 'note');
// Then, detect table changes
const { positionChanges, removeChanges, sizeChanges } =
findRelevantNodesChanges(changesToApply, 'table');
@@ -1144,6 +1177,49 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
}
}
// Handle note changes
if (
notePositionChanges.length > 0 ||
noteRemoveChanges.length > 0 ||
noteSizeChanges.length > 0
) {
const notesUpdates: Record<string, Partial<Note>> = {};
// Handle note position changes
notePositionChanges.forEach((change) => {
if (change.type === 'position' && change.position) {
notesUpdates[change.id] = {
...notesUpdates[change.id],
x: change.position.x,
y: change.position.y,
};
}
});
// Handle note size changes
noteSizeChanges.forEach((change) => {
if (change.type === 'dimensions' && change.dimensions) {
notesUpdates[change.id] = {
...notesUpdates[change.id],
width: change.dimensions.width,
height: change.dimensions.height,
};
}
});
// Handle note removal
noteRemoveChanges.forEach((change) => {
removeNote(change.id);
delete notesUpdates[change.id];
});
// Apply note updates to storage
if (Object.keys(notesUpdates).length > 0) {
for (const [id, updates] of Object.entries(notesUpdates)) {
updateNote(id, updates);
}
}
}
return onNodesChange(changesToApply);
},
[
@@ -1153,6 +1229,8 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
findRelevantNodesChanges,
updateArea,
removeArea,
updateNote,
removeNote,
readonly,
tables,
areas,
@@ -1423,23 +1501,35 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
return [...edges, tempEdge];
}, [edges, tempFloatingEdge, cursorPosition, hoveringTableId]);
const onPaneClickHandler = useCallback(() => {
if (tempFloatingEdge) {
endFloatingEdgeCreation();
setCursorPosition(null);
}
const onPaneClickHandler = useCallback(
(event: React.MouseEvent<Element, MouseEvent>) => {
if (tempFloatingEdge) {
endFloatingEdgeCreation();
setCursorPosition(null);
}
// Close CreateRelationshipNode if it exists
hideCreateRelationshipNode();
// Close CreateRelationshipNode if it exists
hideCreateRelationshipNode();
// Exit edit table mode
exitEditTableMode();
}, [
tempFloatingEdge,
exitEditTableMode,
endFloatingEdgeCreation,
hideCreateRelationshipNode,
]);
// Exit edit table mode
exitEditTableMode();
canvasEvents.emit({
action: 'pan_click',
data: {
x: event.clientX,
y: event.clientY,
},
});
},
[
canvasEvents,
tempFloatingEdge,
exitEditTableMode,
endFloatingEdgeCreation,
hideCreateRelationshipNode,
]
);
return (
<CanvasContextMenu>

View File

@@ -0,0 +1,229 @@
import React, { useCallback, useState, useRef } from 'react';
import { NodeResizer, type NodeProps, type Node } from '@xyflow/react';
import { Pencil, Trash2 } from 'lucide-react';
import type { Note } from '@/lib/domain/note';
import { useChartDB } from '@/hooks/use-chartdb';
import { useClickAway, useKeyPressEvent } from 'react-use';
import { ColorPicker } from '@/components/color-picker/color-picker';
import { Button } from '@/components/button/button';
import { cn } from '@/lib/utils';
import { useCanvas } from '@/hooks/use-canvas';
import type { CanvasEvent } from '@/context/canvas-context/canvas-context';
import { useTheme } from '@/hooks/use-theme';
export interface NoteNodeProps extends NodeProps {
data: {
note: Note;
};
}
export type NoteNodeType = Node<{ note: Note }, 'note'>;
export const NoteNode: React.FC<NoteNodeProps> = ({
data,
selected,
dragging,
}) => {
const { note } = data;
const { updateNote, removeNote, readonly } = useChartDB();
const [editMode, setEditMode] = useState(false);
const [content, setContent] = useState(note.content);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { events } = useCanvas();
const { effectiveTheme } = useTheme();
const focused = !!selected && !dragging;
const saveContent = useCallback(() => {
if (!editMode) return;
updateNote(note.id, { content: content.trim() });
setEditMode(false);
}, [editMode, content, note.id, updateNote]);
const abortEdit = useCallback(() => {
setEditMode(false);
setContent(note.content);
}, [note.content]);
const enterEditMode = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (readonly) return;
setEditMode(true);
},
[readonly]
);
const handleDelete = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
removeNote(note.id);
},
[note.id, removeNote]
);
const handleColorChange = useCallback(
(color: string) => {
updateNote(note.id, { color });
},
[note.id, updateNote]
);
const handleDoubleClick = useCallback<
React.MouseEventHandler<HTMLDivElement>
>(
(e) => {
if (!readonly) {
enterEditMode(e);
}
},
[enterEditMode, readonly]
);
useClickAway(textareaRef, saveContent);
useKeyPressEvent('Escape', abortEdit);
const eventConsumer = useCallback(
(event: CanvasEvent) => {
if (!editMode) {
return;
}
if (event.action === 'pan_click') {
saveContent();
}
},
[editMode, saveContent]
);
events.useSubscription(eventConsumer);
// Focus textarea when entering edit mode
React.useEffect(() => {
if (textareaRef.current && editMode) {
textareaRef.current.focus();
}
}, [editMode]);
const getHeaderColor = (color: string) => {
// Return the original color for header (full saturation)
return color;
};
const getBodyColor = (color: string) => {
const hex = color.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
const isDark = effectiveTheme === 'dark';
if (isDark) {
// Dark mode: darken the color by mixing with dark gray (30% original + 70% dark)
const darkR = Math.round(r * 0.3 + 0 * 0.7);
const darkG = Math.round(g * 0.3 + 0 * 0.7);
const darkB = Math.round(b * 0.3 + 0 * 0.7);
return `rgb(${darkR}, ${darkG}, ${darkB})`;
} else {
// Light mode: lighten the color by mixing with white (30% original + 70% white)
const lightR = Math.round(r * 0.3 + 255 * 0.7);
const lightG = Math.round(g * 0.3 + 255 * 0.7);
const lightB = Math.round(b * 0.3 + 255 * 0.7);
return `rgb(${lightR}, ${lightG}, ${lightB})`;
}
};
return (
<div
className={cn(
'flex h-full flex-col overflow-hidden rounded-[6px] border',
selected
? 'border-pink-600'
: 'border-slate-500 dark:border-slate-600'
)}
style={{
background: getBodyColor(note.color),
}}
onDoubleClick={handleDoubleClick}
>
{/* Notepad header with binding */}
<div
className="relative flex h-2 shrink-0 items-center justify-center"
style={{
background: getHeaderColor(note.color),
}}
/>
{focused && !readonly ? (
<NodeResizer
minWidth={200}
minHeight={150}
isVisible={selected}
lineClassName="!border-pink-500"
handleClassName="!h-3 !w-3 !bg-pink-500 !rounded-full"
/>
) : null}
{/* Note body */}
<div className="group/note relative flex-1 overflow-hidden p-2">
{/* Corner fold (bottom-right) */}
<div className="absolute bottom-0 right-0 border-b-[30px] border-l-[30px] border-r-0 border-t-0 border-b-black/10 border-l-transparent opacity-50 dark:border-b-white/10" />
{/* Content area */}
{editMode ? (
<textarea
ref={textareaRef}
className="size-full resize-none overflow-auto border-none bg-transparent p-0 text-sm leading-relaxed text-gray-700 outline-none dark:text-gray-300"
value={content}
onChange={(e) => setContent(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
saveContent();
}
}}
autoFocus
placeholder="Type your note here..."
/>
) : (
<div className="h-full overflow-auto whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300">
{note.content || (
<span className="italic text-muted-foreground">
Double-click to add text...
</span>
)}
</div>
)}
{/* Quick actions on hover */}
{!editMode && !readonly && (
<div className="absolute right-2 top-2 flex gap-1 rounded bg-white/90 p-1 opacity-0 shadow-md transition-opacity group-hover/note:opacity-100 dark:bg-slate-800/90">
<Button
variant="ghost"
size="sm"
className="size-7 p-0"
onClick={enterEditMode}
>
<Pencil className="size-3.5" />
</Button>
<ColorPicker
color={note.color}
onChange={handleColorChange}
/>
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-red-500 hover:text-red-700"
onClick={handleDelete}
>
<Trash2 className="size-3.5" />
</Button>
</div>
)}
</div>
</div>
);
};
NoteNode.displayName = 'NoteNode';

View File

@@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next';
import { SelectBox } from '@/components/select-box/select-box';
import { cn } from '@/lib/utils';
import { TableFieldToggle } from './table-field-toggle';
import { requiresNotNull } from '@/lib/data/data-types/data-types';
export interface TableEditModeFieldProps {
table: DBTable;
@@ -41,6 +42,8 @@ export const TableEditModeField: React.FC<TableEditModeFieldProps> = React.memo(
const inputRef = React.useRef<HTMLInputElement>(null);
const typeRequiresNotNull = requiresNotNull(field.type.name);
// Animate the highlight after mount if focused
useEffect(() => {
if (focused) {
@@ -135,6 +138,7 @@ export const TableEditModeField: React.FC<TableEditModeFieldProps> = React.memo(
<TableFieldToggle
pressed={nullable}
onPressedChange={handleNullableToggle}
disabled={typeRequiresNotNull}
>
N
</TableFieldToggle>

View File

@@ -42,8 +42,12 @@ export interface SidebarItem {
export interface EditorSidebarProps {}
export const EditorSidebar: React.FC<EditorSidebarProps> = () => {
const { selectSidebarSection, selectedSidebarSection, showSidePanel } =
useLayout();
const {
selectSidebarSection,
selectedSidebarSection,
showSidePanel,
selectVisualsTab,
} = useLayout();
const { t } = useTranslation();
const { isMd: isDesktop } = useBreakpoint('md');
const { effectiveTheme } = useTheme();
@@ -101,15 +105,6 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = () => {
},
active: selectedSidebarSection === 'refs',
},
{
title: t('editor_sidebar.areas'),
icon: Group,
onClick: () => {
showSidePanel();
selectSidebarSection('areas');
},
active: selectedSidebarSection === 'areas',
},
...(supportsCustomTypes(databaseType)
? [
{
@@ -123,6 +118,16 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = () => {
},
]
: []),
{
title: t('editor_sidebar.visuals'),
icon: Group,
onClick: () => {
showSidePanel();
selectSidebarSection('visuals');
selectVisualsTab('areas');
},
active: selectedSidebarSection === 'visuals',
},
],
[
selectSidebarSection,
@@ -130,6 +135,7 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = () => {
t,
showSidePanel,
databaseType,
selectVisualsTab,
]
);

View File

@@ -13,11 +13,11 @@ import type { SidebarSection } from '@/context/layout-context/layout-context';
import { useTranslation } from 'react-i18next';
import { useChartDB } from '@/hooks/use-chartdb';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { AreasSection } from './areas-section/areas-section';
import { CustomTypesSection } from './custom-types-section/custom-types-section';
import { supportsCustomTypes } from '@/lib/domain/database-capabilities';
import { DBMLSection } from './dbml-section/dbml-section';
import { RefsSection } from './refs-section/refs-section';
import { VisualsSection } from './visuals-section/visuals-section';
export interface SidePanelProps {}
@@ -54,6 +54,9 @@ export const SidePanel: React.FC<SidePanelProps> = () => {
<SelectItem value="areas">
{t('side_panel.areas_section.areas')}
</SelectItem>
<SelectItem value="visuals">
{t('side_panel.visuals_section.visuals')}
</SelectItem>
{supportsCustomTypes(databaseType) ? (
<SelectItem value="customTypes">
{t(
@@ -72,8 +75,8 @@ export const SidePanel: React.FC<SidePanelProps> = () => {
<DBMLSection />
) : selectedSidebarSection === 'refs' ? (
<RefsSection />
) : selectedSidebarSection === 'areas' ? (
<AreasSection />
) : selectedSidebarSection === 'visuals' ? (
<VisualsSection />
) : (
<CustomTypesSection />
)}

View File

@@ -9,6 +9,7 @@ import {
findDataTypeDataById,
supportsAutoIncrementDataType,
supportsArrayDataType,
autoIncrementAlwaysOn,
} from '@/lib/data/data-types/data-types';
import {
Popover,
@@ -111,6 +112,18 @@ export const TableFieldPopover: React.FC<TableFieldPopoverProps> = ({
[field.type.name, databaseType]
);
// Check if this is a SERIAL-type that is inherently auto-incrementing
const forceAutoIncrement = useMemo(
() => autoIncrementAlwaysOn(field.type.name) && !localField.nullable,
[field.type.name, localField.nullable]
);
// Auto-increment is disabled if the field is nullable (auto-increment requires NOT NULL)
const isIncrementDisabled = useMemo(
() => localField.nullable || readonly || forceAutoIncrement,
[localField.nullable, readonly, forceAutoIncrement]
);
return (
<Popover
open={isOpen}
@@ -166,10 +179,12 @@ export const TableFieldPopover: React.FC<TableFieldPopoverProps> = ({
)}
</Label>
<Checkbox
checked={localField.increment ?? false}
disabled={
!localField.primaryKey || readonly
checked={
forceAutoIncrement
? true
: (localField.increment ?? false)
}
disabled={isIncrementDisabled}
onCheckedChange={(value) =>
setLocalField((current) => ({
...current,

View File

@@ -15,6 +15,7 @@ import { CSS } from '@dnd-kit/utilities';
import { SelectBox } from '@/components/select-box/select-box';
import { TableFieldPopover } from './table-field-modal/table-field-modal';
import type { DatabaseType, DBTable } from '@/lib/domain';
import { requiresNotNull } from '@/lib/data/data-types/data-types';
export interface TableFieldProps {
table: DBTable;
@@ -55,6 +56,8 @@ export const TableField: React.FC<TableFieldProps> = ({
transition,
};
const typeRequiresNotNull = requiresNotNull(field.type.name);
return (
<div
className="flex flex-1 touch-none flex-row justify-between gap-2 p-1"
@@ -130,7 +133,7 @@ export const TableField: React.FC<TableFieldProps> = ({
<TableFieldToggle
pressed={nullable}
onPressedChange={handleNullableToggle}
disabled={readonly}
disabled={readonly || typeRequiresNotNull}
>
N
</TableFieldToggle>

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useMemo } from 'react';
import { AreaList } from './areas-list/areas-list';
import { Button } from '@/components/button/button';
import { Group, X } from 'lucide-react';
import { Input } from '@/components/input/input';
@@ -10,10 +9,11 @@ import { EmptyState } from '@/components/empty-state/empty-state';
import { ScrollArea } from '@/components/scroll-area/scroll-area';
import { useTranslation } from 'react-i18next';
import { useViewport } from '@xyflow/react';
import { AreaList } from './areas-list/areas-list';
export interface AreasSectionProps {}
export interface AreasTabProps {}
export const AreasSection: React.FC<AreasSectionProps> = () => {
export const AreasTab: React.FC<AreasTabProps> = () => {
const { createArea, areas, readonly } = useChartDB();
const viewport = useViewport();
const { t } = useTranslation();
@@ -58,11 +58,8 @@ export const AreasSection: React.FC<AreasSectionProps> = () => {
}, []);
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 flex-1 flex-col overflow-hidden px-2">
<div className="flex items-center justify-between gap-4 pb-1">
<div className="flex-1">
<Input
ref={filterInputRef}
@@ -116,6 +113,6 @@ export const AreasSection: React.FC<AreasSectionProps> = () => {
)}
</ScrollArea>
</div>
</section>
</div>
);
};

View File

@@ -0,0 +1,157 @@
import React, { useCallback } from 'react';
import {
GripVertical,
Trash2,
EllipsisVertical,
CircleDotDashed,
} from 'lucide-react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { Note } from '@/lib/domain/note';
import { useChartDB } from '@/hooks/use-chartdb';
import { useTranslation } from 'react-i18next';
import { ColorPicker } from '@/components/color-picker/color-picker';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/dropdown-menu/dropdown-menu';
import { ListItemHeaderButton } from '@/pages/editor-page/side-panel/list-item-header-button/list-item-header-button';
import { useFocusOn } from '@/hooks/use-focus-on';
import { mergeRefs } from '@/lib/utils';
export interface NoteListItemProps {
note: Note;
}
export const NoteListItem = React.forwardRef<HTMLDivElement, NoteListItemProps>(
({ note }, forwardedRef) => {
const { updateNote, removeNote, readonly } = useChartDB();
const { t } = useTranslation();
const { focusOnNote } = useFocusOn();
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({
id: note.id,
});
// Merge the forwarded ref with the sortable ref
const combinedRef = mergeRefs<HTMLDivElement>(forwardedRef, setNodeRef);
const style = {
transform: CSS.Translate.toString(transform),
transition,
};
const handleDelete = useCallback(() => {
removeNote(note.id);
}, [note.id, removeNote]);
const handleColorChange = useCallback(
(color: string) => {
updateNote(note.id, { color });
},
[note.id, updateNote]
);
const handleFocusOnNote = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.stopPropagation();
focusOnNote(note.id);
},
[focusOnNote, note.id]
);
const renderDropDownMenu = useCallback(
() => (
<DropdownMenu>
<DropdownMenuTrigger>
<ListItemHeaderButton>
<EllipsisVertical />
</ListItemHeaderButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-fit min-w-40">
<DropdownMenuLabel>
{t(
'side_panel.notes_section.note.note_actions.title'
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={handleDelete}
className="flex justify-between !text-red-700"
>
{t(
'side_panel.notes_section.note.note_actions.delete_note'
)}
<Trash2 className="size-3.5 text-red-700" />
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
),
[handleDelete, t]
);
return (
<div
className="w-full rounded-md border border-border hover:bg-accent/5"
ref={combinedRef}
style={{
...style,
borderLeftWidth: '6px',
borderLeftColor: note.color,
}}
{...attributes}
>
<div className="group flex min-h-11 items-center justify-between gap-1 overflow-hidden p-2">
{!readonly ? (
<div
className="flex cursor-move items-center justify-center"
{...listeners}
>
<GripVertical className="size-4 text-muted-foreground" />
</div>
) : null}
<div className="flex min-w-0 flex-1">
<div className="truncate px-2 py-0.5 text-sm">
{note.content || (
<span className="italic text-muted-foreground">
{t(
'side_panel.notes_section.note.empty_note'
)}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
<div className="flex flex-row-reverse items-center gap-1">
{!readonly ? renderDropDownMenu() : null}
<ColorPicker
color={note.color}
onChange={handleColorChange}
disabled={readonly}
/>
<div className="hidden md:group-hover:flex">
<ListItemHeaderButton
onClick={handleFocusOnNote}
>
<CircleDotDashed />
</ListItemHeaderButton>
</div>
</div>
</div>
</div>
</div>
);
}
);
NoteListItem.displayName = 'NoteListItem';

View File

@@ -0,0 +1,123 @@
import React, { useCallback, useMemo } from 'react';
import { NoteListItem } from './note-list-item/note-list-item';
import type { Note } from '@/lib/domain/note';
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';
export interface NotesListProps {
notes: Note[];
}
export const NotesList: React.FC<NotesListProps> = ({ notes }) => {
const { updateNote } = useChartDB();
const { openedNoteInSidebar } = useLayout();
const lastSelectedNote = React.useRef<string | null>(null);
const refs = useMemo(
() =>
notes.reduce(
(acc, note) => {
acc[note.id] = React.createRef();
return acc;
},
{} as Record<string, React.RefObject<HTMLDivElement>>
),
[notes]
);
const scrollToNote = useCallback(
(id: string) =>
refs[id]?.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
}),
[refs]
);
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (active?.id !== over?.id && !!over && !!active) {
const oldIndex = notes.findIndex((note) => note.id === active.id);
const newIndex = notes.findIndex((note) => note.id === over.id);
const newNotesOrder = arrayMove<Note>(notes, oldIndex, newIndex);
newNotesOrder.forEach((note, index) => {
updateNote(note.id, { order: index });
});
}
};
const handleScrollToNote = useCallback(() => {
if (
openedNoteInSidebar &&
lastSelectedNote.current !== openedNoteInSidebar
) {
lastSelectedNote.current = openedNoteInSidebar;
scrollToNote(openedNoteInSidebar);
}
}, [scrollToNote, openedNoteInSidebar]);
React.useEffect(() => {
handleScrollToNote();
}, [openedNoteInSidebar, handleScrollToNote]);
return (
<div className="flex w-full flex-col gap-1">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={notes}
strategy={verticalListSortingStrategy}
>
{notes
.sort((note1: Note, note2: Note) => {
if (note1.order && note2.order === undefined) {
return -1;
}
if (note1.order === undefined && note2.order) {
return 1;
}
if (
note1.order !== undefined &&
note2.order !== undefined
) {
return note1.order - note2.order;
}
// if both notes don't have order, sort by content
return note1.content.localeCompare(note2.content);
})
.map((note) => (
<NoteListItem
key={note.id}
note={note}
ref={refs[note.id]}
/>
))}
</SortableContext>
</DndContext>
</div>
);
};

View File

@@ -0,0 +1,118 @@
import React, { useCallback, useMemo } from 'react';
import { Button } from '@/components/button/button';
import { StickyNote, X } from 'lucide-react';
import { Input } from '@/components/input/input';
import type { Note } from '@/lib/domain/note';
import { useChartDB } from '@/hooks/use-chartdb';
import { useLayout } from '@/hooks/use-layout';
import { EmptyState } from '@/components/empty-state/empty-state';
import { ScrollArea } from '@/components/scroll-area/scroll-area';
import { useTranslation } from 'react-i18next';
import { useViewport } from '@xyflow/react';
import { NotesList } from './notes-list/notes-list';
export interface NotesTabProps {}
export const NotesTab: React.FC<NotesTabProps> = () => {
const { createNote, notes, readonly } = useChartDB();
const viewport = useViewport();
const { t } = useTranslation();
const { openNoteFromSidebar } = useLayout();
const [filterText, setFilterText] = React.useState('');
const filterInputRef = React.useRef<HTMLInputElement>(null);
const filteredNotes = useMemo(() => {
const filterNoteContent: (note: Note) => boolean = (note) =>
!filterText?.trim?.() ||
note.content.toLowerCase().includes(filterText.toLowerCase());
return notes.filter(filterNoteContent);
}, [notes, filterText]);
const createNoteWithLocation = useCallback(async () => {
const padding = 80;
const centerX = -viewport.x / viewport.zoom + padding / viewport.zoom;
const centerY = -viewport.y / viewport.zoom + padding / viewport.zoom;
const note = await createNote({
x: centerX,
y: centerY,
});
if (openNoteFromSidebar) {
openNoteFromSidebar(note.id);
}
}, [
createNote,
openNoteFromSidebar,
viewport.x,
viewport.y,
viewport.zoom,
]);
const handleCreateNote = useCallback(async () => {
setFilterText('');
createNoteWithLocation();
}, [createNoteWithLocation, setFilterText]);
const handleClearFilter = useCallback(() => {
setFilterText('');
}, []);
return (
<div className="flex flex-1 flex-col overflow-hidden px-2">
<div className="flex items-center justify-between gap-4 pb-1">
<div className="flex-1">
<Input
ref={filterInputRef}
type="text"
placeholder={t('side_panel.notes_section.filter')}
className="h-8 w-full focus-visible:ring-0"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
/>
</div>
{!readonly ? (
<Button
variant="secondary"
className="h-8 p-2 text-xs"
onClick={handleCreateNote}
>
<StickyNote className="h-4" />
{t('side_panel.notes_section.add_note')}
</Button>
) : null}
</div>
<div className="flex flex-1 flex-col overflow-hidden">
<ScrollArea className="h-full">
{notes.length === 0 ? (
<EmptyState
title={t(
'side_panel.notes_section.empty_state.title'
)}
description={t(
'side_panel.notes_section.empty_state.description'
)}
className="mt-20"
/>
) : filterText && filteredNotes.length === 0 ? (
<div className="mt-10 flex flex-col items-center gap-2">
<div className="text-sm text-muted-foreground">
{t('side_panel.notes_section.no_results')}
</div>
<Button
variant="outline"
size="sm"
onClick={handleClearFilter}
className="gap-1"
>
<X className="size-3.5" />
{t('side_panel.notes_section.clear')}
</Button>
</div>
) : (
<NotesList notes={filteredNotes} />
)}
</ScrollArea>
</div>
</div>
);
};

View File

@@ -0,0 +1,68 @@
import React from 'react';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/tabs/tabs';
import { AreasTab } from './areas-tab/areas-tab';
import { NotesTab } from './notes-tab/notes-tab';
import { useTranslation } from 'react-i18next';
import { useLayout } from '@/hooks/use-layout';
import type { VisualsTab } from '@/context/layout-context/layout-context';
import { Separator } from '@/components/separator/separator';
import { Group, StickyNote } from 'lucide-react';
export interface VisualsSectionProps {}
export const VisualsSection: React.FC<VisualsSectionProps> = () => {
const { t } = useTranslation();
const { selectedVisualsTab, selectVisualsTab } = useLayout();
return (
<section
className="flex flex-1 flex-col overflow-hidden"
data-vaul-no-drag
>
<Tabs
value={selectedVisualsTab}
onValueChange={(value) => selectVisualsTab(value as VisualsTab)}
className="flex flex-1 flex-col overflow-hidden"
>
<div className="px-2 pt-2">
<TabsList className="grid h-auto w-full grid-cols-2 gap-1 rounded-xl border bg-background p-1">
<TabsTrigger
value="areas"
className="gap-1.5 rounded-lg px-3 py-1 text-sm font-medium transition-all data-[state=active]:bg-sky-600 data-[state=active]:text-white data-[state=inactive]:text-muted-foreground data-[state=active]:shadow-sm data-[state=inactive]:hover:bg-muted/50 data-[state=inactive]:hover:text-foreground dark:data-[state=active]:bg-sky-500"
>
<Group className="size-3.5" />
{t('side_panel.visuals_section.tabs.areas')}
</TabsTrigger>
<TabsTrigger
value="notes"
className="gap-1.5 rounded-lg px-3 py-1 text-sm font-medium transition-all data-[state=active]:bg-sky-600 data-[state=active]:text-white data-[state=inactive]:text-muted-foreground data-[state=active]:shadow-sm data-[state=inactive]:hover:bg-muted/50 data-[state=inactive]:hover:text-foreground dark:data-[state=active]:bg-sky-500"
>
<StickyNote className="size-3.5" />
{t('side_panel.visuals_section.tabs.notes')}
</TabsTrigger>
</TabsList>
<Separator orientation="horizontal" className="my-2" />
</div>
<TabsContent
value="areas"
className="mt-0 flex flex-1 flex-col overflow-hidden data-[state=inactive]:hidden"
>
<AreasTab />
</TabsContent>
<TabsContent
value="notes"
className="mt-0 flex flex-1 flex-col overflow-hidden data-[state=inactive]:hidden"
>
<NotesTab />
</TabsContent>
</Tabs>
</section>
);
};