From 94a5d84fae819b0de6c1e471d1aad16dc8f39dd6 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Sun, 10 Nov 2024 16:30:15 +0200 Subject: [PATCH] feat(share): add sharing capabilities to import and export diagrams (#365) * feat(share): add sharing capabilities to import and export diagrams * remove use client * fix build * add error parse indication * add import from initial dialog * fix build --- package-lock.json | 4 +- package.json | 3 +- src/components/alert/alert.tsx | 62 ++ .../file-uploader/file-uploader.tsx | 168 ++++ src/context/dialog-context/dialog-context.tsx | 18 + .../dialog-context/dialog-provider.tsx | 18 + .../select-database/select-database.tsx | 10 +- .../export-diagram-dialog.tsx | 101 +++ .../import-diagram-dialog.tsx | 129 +++ src/i18n/locales/de.ts | 29 +- src/i18n/locales/en.ts | 24 + src/i18n/locales/es.ts | 29 +- src/i18n/locales/fr.ts | 29 +- src/i18n/locales/hi.ts | 29 +- src/i18n/locales/ja.ts | 29 +- src/i18n/locales/ko_KR.ts | 29 +- src/i18n/locales/pt_BR.ts | 29 +- src/i18n/locales/ru.ts | 29 +- src/i18n/locales/uk.ts | 29 +- src/lib/data/data-types/data-types.ts | 6 + src/lib/domain/db-dependency.ts | 10 + src/lib/domain/db-field.ts | 19 +- src/lib/domain/db-index.ts | 9 + src/lib/domain/db-relationship.ts | 15 + src/lib/domain/db-table.ts | 30 +- src/lib/domain/diagram.ts | 31 +- src/lib/export-import-utils.ts | 33 + src/lib/utils.ts | 92 +++ .../editor-page/top-navbar/top-navbar.tsx | 732 +++++++++--------- src/templates-data/template-utils.ts | 80 +- 30 files changed, 1377 insertions(+), 478 deletions(-) create mode 100644 src/components/alert/alert.tsx create mode 100644 src/components/file-uploader/file-uploader.tsx create mode 100644 src/dialogs/export-diagram-dialog/export-diagram-dialog.tsx create mode 100644 src/dialogs/import-diagram-dialog/import-diagram-dialog.tsx create mode 100644 src/lib/export-import-utils.ts diff --git a/package-lock.json b/package-lock.json index cc16e1969..fe3cba618 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,8 @@ "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", "timeago-react": "^3.0.6", - "vaul": "^0.9.1" + "vaul": "^0.9.1", + "zod": "^3.23.8" }, "devDependencies": { "@types/node": "^22.1.0", @@ -10539,7 +10540,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index b8a18d4bb..83eefede6 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,8 @@ "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", "timeago-react": "^3.0.6", - "vaul": "^0.9.1" + "vaul": "^0.9.1", + "zod": "^3.23.8" }, "devDependencies": { "@types/node": "^22.1.0", diff --git a/src/components/alert/alert.tsx b/src/components/alert/alert.tsx new file mode 100644 index 000000000..3777eb328 --- /dev/null +++ b/src/components/alert/alert.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/src/components/file-uploader/file-uploader.tsx b/src/components/file-uploader/file-uploader.tsx new file mode 100644 index 000000000..5974be584 --- /dev/null +++ b/src/components/file-uploader/file-uploader.tsx @@ -0,0 +1,168 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Upload, FileIcon, AlertCircle, Trash2 } from 'lucide-react'; +import { Button } from '../button/button'; + +interface FileWithPreview extends File { + preview?: string; +} + +export interface FileUploaderProps { + onFilesChange?: (files: File[]) => void; + multiple?: boolean; + supportedExtensions?: string[]; +} + +export const FileUploader: React.FC = ({ + onFilesChange, + multiple, + supportedExtensions, +}) => { + const [files, setFiles] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const [error, setError] = useState(null); + + const isFileSupported = useCallback( + (file: File) => { + if (!supportedExtensions) return true; + const fileExtension = file.name.split('.').pop()?.toLowerCase(); + return fileExtension + ? supportedExtensions.includes(`.${fileExtension}`) + : false; + }, + [supportedExtensions] + ); + + const handleFiles = useCallback( + (selectedFiles: FileList) => { + const newFiles = Array.from(selectedFiles) + .filter((file) => { + if (!isFileSupported(file)) { + setError( + `File type not supported. Supported types: ${supportedExtensions?.join(', ')}` + ); + return false; + } + return true; + }) + .map((file) => + Object.assign(file, { preview: URL.createObjectURL(file) }) + ); + + if (newFiles.length === 0) return; + + setError(null); + setFiles((prevFiles) => { + if (multiple) { + return [...prevFiles, ...newFiles]; + } else { + return newFiles.slice(0, 1); + } + }); + }, + [multiple, supportedExtensions, isFileSupported] + ); + + const onDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const onDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }, []); + + const onDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + handleFiles(e.dataTransfer.files); + } + }, + [handleFiles] + ); + + useEffect(() => { + if (onFilesChange) { + onFilesChange(files.length > 0 ? files : []); + } + }, [files, onFilesChange]); + + const removeFile = useCallback((fileToRemove: File) => { + setFiles((prevFiles) => + prevFiles.filter((file) => file !== fileToRemove) + ); + }, []); + + return ( +
+
+ + e.target.files && handleFiles(e.target.files) + } + className="hidden" + id="fileInput" + accept={supportedExtensions?.join(',')} + /> + +
+ + {error ? ( +
+ + {error} +
+ ) : null} + + {files.length > 0 ? ( +
    + {files.map((file) => ( +
  • +
    + + + {file.name} + +
    + +
  • + ))} +
+ ) : null} +
+ ); +}; diff --git a/src/context/dialog-context/dialog-context.tsx b/src/context/dialog-context/dialog-context.tsx index fd3ff8ec9..b0eb42d3e 100644 --- a/src/context/dialog-context/dialog-context.tsx +++ b/src/context/dialog-context/dialog-context.tsx @@ -5,6 +5,8 @@ import type { TableSchemaDialogProps } from '@/dialogs/table-schema-dialog/table import type { ImportDatabaseDialogProps } from '@/dialogs/import-database-dialog/import-database-dialog'; import type { ExportSQLDialogProps } from '@/dialogs/export-sql-dialog/export-sql-dialog'; import type { ExportImageDialogProps } from '@/dialogs/export-image-dialog/export-image-dialog'; +import type { ExportDiagramDialogProps } from '@/dialogs/export-diagram-dialog/export-diagram-dialog'; +import type { ImportDiagramDialogProps } from '@/dialogs/import-diagram-dialog/import-diagram-dialog'; export interface DialogContext { // Create diagram dialog @@ -48,6 +50,18 @@ export interface DialogContext { params: Omit ) => void; closeExportImageDialog: () => void; + + // Export diagram dialog + openExportDiagramDialog: ( + params: Omit + ) => void; + closeExportDiagramDialog: () => void; + + // Import diagram dialog + openImportDiagramDialog: ( + params: Omit + ) => void; + closeImportDiagramDialog: () => void; } export const dialogContext = createContext({ @@ -69,4 +83,8 @@ export const dialogContext = createContext({ closeStarUsDialog: emptyFn, openExportImageDialog: emptyFn, closeExportImageDialog: emptyFn, + openExportDiagramDialog: emptyFn, + closeExportDiagramDialog: emptyFn, + openImportDiagramDialog: emptyFn, + closeImportDiagramDialog: emptyFn, }); diff --git a/src/context/dialog-context/dialog-provider.tsx b/src/context/dialog-context/dialog-provider.tsx index 321d9af25..06902be7b 100644 --- a/src/context/dialog-context/dialog-provider.tsx +++ b/src/context/dialog-context/dialog-provider.tsx @@ -17,6 +17,8 @@ import { emptyFn } from '@/lib/utils'; import { StarUsDialog } from '@/dialogs/star-us-dialog/star-us-dialog'; import type { ExportImageDialogProps } from '@/dialogs/export-image-dialog/export-image-dialog'; import { ExportImageDialog } from '@/dialogs/export-image-dialog/export-image-dialog'; +import { ExportDiagramDialog } from '@/dialogs/export-diagram-dialog/export-diagram-dialog'; +import { ImportDiagramDialog } from '@/dialogs/import-diagram-dialog/import-diagram-dialog'; export const DialogProvider: React.FC = ({ children, @@ -86,6 +88,14 @@ export const DialogProvider: React.FC = ({ [setOpenTableSchemaDialog] ); + // Export image dialog + const [openExportDiagramDialog, setOpenExportDiagramDialog] = + useState(false); + + // Import diagram dialog + const [openImportDiagramDialog, setOpenImportDiagramDialog] = + useState(false); + // Alert dialog const [showAlert, setShowAlert] = useState(false); const [alertParams, setAlertParams] = useState({ @@ -126,6 +136,12 @@ export const DialogProvider: React.FC = ({ closeStarUsDialog: () => setOpenStarUsDialog(false), closeExportImageDialog: () => setOpenExportImageDialog(false), openExportImageDialog: openExportImageDialogHandler, + openExportDiagramDialog: () => setOpenExportDiagramDialog(true), + closeExportDiagramDialog: () => + setOpenExportDiagramDialog(false), + openImportDiagramDialog: () => setOpenImportDiagramDialog(true), + closeImportDiagramDialog: () => + setOpenImportDiagramDialog(false), }} > {children} @@ -152,6 +168,8 @@ export const DialogProvider: React.FC = ({ dialog={{ open: openExportImageDialog }} {...exportImageDialogParams} /> + + ); }; diff --git a/src/dialogs/create-diagram-dialog/select-database/select-database.tsx b/src/dialogs/create-diagram-dialog/select-database/select-database.tsx index 6d47b1e77..9a55e1e39 100644 --- a/src/dialogs/create-diagram-dialog/select-database/select-database.tsx +++ b/src/dialogs/create-diagram-dialog/select-database/select-database.tsx @@ -10,6 +10,7 @@ import { import { DatabaseType } from '@/lib/domain/database-type'; import { useTranslation } from 'react-i18next'; import { SelectDatabaseContent } from './select-database-content'; +import { useDialog } from '@/hooks/use-dialog'; export interface SelectDatabaseProps { onContinue: () => void; @@ -27,6 +28,7 @@ export const SelectDatabase: React.FC = ({ createNewDiagram, }) => { const { t } = useTranslation(); + const { openImportDiagramDialog } = useDialog(); return ( <> @@ -51,7 +53,13 @@ export const SelectDatabase: React.FC = ({ ) : ( -
+ )}
+ + + + + + + + ); +}; diff --git a/src/dialogs/import-diagram-dialog/import-diagram-dialog.tsx b/src/dialogs/import-diagram-dialog/import-diagram-dialog.tsx new file mode 100644 index 000000000..7dc5a0706 --- /dev/null +++ b/src/dialogs/import-diagram-dialog/import-diagram-dialog.tsx @@ -0,0 +1,129 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDialog } from '@/hooks/use-dialog'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/dialog/dialog'; +import { Button } from '@/components/button/button'; +import type { BaseDialogProps } from '../common/base-dialog-props'; +import { useTranslation } from 'react-i18next'; +import { FileUploader } from '@/components/file-uploader/file-uploader'; +import { useStorage } from '@/hooks/use-storage'; +import { useNavigate } from 'react-router-dom'; +import { diagramFromJSONInput } from '@/lib/export-import-utils'; +import { Alert, AlertDescription, AlertTitle } from '@/components/alert/alert'; +import { AlertCircle } from 'lucide-react'; + +export interface ImportDiagramDialogProps extends BaseDialogProps {} + +export const ImportDiagramDialog: React.FC = ({ + dialog, +}) => { + const { t } = useTranslation(); + const [file, setFile] = useState(null); + const { addDiagram } = useStorage(); + const navigate = useNavigate(); + const [error, setError] = useState(false); + + const onFileChange = useCallback((files: File[]) => { + if (files.length === 0) { + setFile(null); + return; + } + + setFile(files[0]); + }, []); + + useEffect(() => { + if (!dialog.open) return; + setError(false); + setFile(null); + }, [dialog.open]); + const { closeImportDiagramDialog, closeCreateDiagramDialog } = useDialog(); + + const handleImport = useCallback(() => { + if (!file) return; + + const reader = new FileReader(); + reader.onload = async (e) => { + const json = e.target?.result; + if (typeof json !== 'string') return; + + try { + const diagram = diagramFromJSONInput(json); + + await addDiagram({ diagram }); + + closeImportDiagramDialog(); + closeCreateDiagramDialog(); + + navigate(`/diagrams/${diagram.id}`); + } catch (e) { + setError(true); + + throw e; + } + }; + reader.readAsText(file); + }, [ + file, + addDiagram, + navigate, + closeImportDiagramDialog, + closeCreateDiagramDialog, + ]); + + return ( + { + if (!open) { + closeImportDiagramDialog(); + } + }} + > + + + + {t('import_diagram_dialog.title')} + + + {t('import_diagram_dialog.description')} + + +
+ + {error ? ( + + + + {t('import_diagram_dialog.error.title')} + + + {t('import_diagram_dialog.error.description')} + + + ) : null} +
+ + + + + + +
+
+ ); +}; diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index fbd4c0b33..954d5fc37 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -32,6 +32,12 @@ export const de: LanguageTranslation = { show_dependencies: 'Abhängigkeiten anzeigen', hide_dependencies: 'Abhängigkeiten ausblenden', }, + // TODO: Translate + share: { + share: 'Share', + export_diagram: 'Export Diagram', + import_diagram: 'Import Diagram', + }, help: { help: 'Hilfe', visit_website: 'ChartDB Webseite', @@ -226,6 +232,8 @@ export const de: LanguageTranslation = { cancel: 'Abbrechen', back: 'Zurück', + // TODO: Translate + import_from_file: 'Import from File', empty_diagram: 'Leeres Diagramm', continue: 'Weiter', import: 'Importieren', @@ -329,7 +337,26 @@ export const de: LanguageTranslation = { close: 'Nicht jetzt', confirm: 'Natürlich!', }, - + // TODO: Translate + export_diagram_dialog: { + title: 'Export Diagram', + description: 'Choose the format for export:', + format_json: 'JSON', + cancel: 'Cancel', + export: 'Export', + }, + // TODO: Translate + import_diagram_dialog: { + title: 'Import Diagram', + description: 'Paste the diagram JSON below:', + cancel: 'Cancel', + import: 'Import', + error: { + title: 'Error importing diagram', + description: + 'The diagram JSON is invalid. Please check the JSON and try again. Need help? chartdb.io@gmail.com', + }, + }, relationship_type: { one_to_one: 'Ein zu Eins (1:1)', one_to_many: 'Ein zu Viele (1:n)', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b6512782b..20336f8c1 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -32,6 +32,11 @@ export const en = { show_dependencies: 'Show Dependencies', hide_dependencies: 'Hide Dependencies', }, + share: { + share: 'Share', + export_diagram: 'Export Diagram', + import_diagram: 'Import Diagram', + }, help: { help: 'Help', visit_website: 'Visit ChartDB', @@ -224,6 +229,7 @@ export const en = { }, cancel: 'Cancel', + import_from_file: 'Import from File', back: 'Back', empty_diagram: 'Empty diagram', continue: 'Continue', @@ -328,7 +334,25 @@ export const en = { close: 'Not now', confirm: 'Of course!', }, + export_diagram_dialog: { + title: 'Export Diagram', + description: 'Choose the format for export:', + format_json: 'JSON', + cancel: 'Cancel', + export: 'Export', + }, + import_diagram_dialog: { + title: 'Import Diagram', + description: 'Paste the diagram JSON below:', + cancel: 'Cancel', + import: 'Import', + error: { + title: 'Error importing diagram', + description: + 'The diagram JSON is invalid. Please check the JSON and try again. Need help? chartdb.io@gmail.com', + }, + }, relationship_type: { one_to_one: 'One to One', one_to_many: 'One to Many', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 7536217b0..7652385ff 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -32,6 +32,12 @@ export const es: LanguageTranslation = { show_dependencies: 'Mostrar dependencias', hide_dependencies: 'Ocultar dependencias', }, + // TODO: Translate + share: { + share: 'Share', + export_diagram: 'Export Diagram', + import_diagram: 'Import Diagram', + }, help: { help: 'Ayuda', visit_website: 'Visitar ChartDB', @@ -216,6 +222,8 @@ export const es: LanguageTranslation = { cancel: 'Cancelar', back: 'Atrás', + // TODO: Translate + import_from_file: 'Import from File', empty_diagram: 'Diagrama vacío', continue: 'Continuar', import: 'Importar', @@ -329,7 +337,26 @@ export const es: LanguageTranslation = { change_schema: 'Cambiar', none: 'nada', }, - + // TODO: Translate + export_diagram_dialog: { + title: 'Export Diagram', + description: 'Choose the format for export:', + format_json: 'JSON', + cancel: 'Cancel', + export: 'Export', + }, + // TODO: Translate + import_diagram_dialog: { + title: 'Import Diagram', + description: 'Paste the diagram JSON below:', + cancel: 'Cancel', + import: 'Import', + error: { + title: 'Error importing diagram', + description: + 'The diagram JSON is invalid. Please check the JSON and try again. Need help? chartdb.io@gmail.com', + }, + }, relationship_type: { one_to_one: 'Uno a Uno', one_to_many: 'Uno a Muchos', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 8fb93d025..9b4f15679 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -32,6 +32,12 @@ export const fr: LanguageTranslation = { show_dependencies: 'Afficher les Dépendances', hide_dependencies: 'Masquer les Dépendances', }, + // TODO: Translate + share: { + share: 'Share', + export_diagram: 'Export Diagram', + import_diagram: 'Import Diagram', + }, help: { help: 'Aide', visit_website: 'Visitez ChartDB', @@ -218,6 +224,8 @@ export const fr: LanguageTranslation = { cancel: 'Annuler', back: 'Retour', + // TODO: Translate + import_from_file: 'Import from File', empty_diagram: 'Diagramme vide', continue: 'Continuer', import: 'Importer', @@ -332,7 +340,26 @@ export const fr: LanguageTranslation = { cancel: 'Annuler', }, }, - + // TODO: Translate + export_diagram_dialog: { + title: 'Export Diagram', + description: 'Choose the format for export:', + format_json: 'JSON', + cancel: 'Cancel', + export: 'Export', + }, + // TODO: Translate + import_diagram_dialog: { + title: 'Import Diagram', + description: 'Paste the diagram JSON below:', + cancel: 'Cancel', + import: 'Import', + error: { + title: 'Error importing diagram', + description: + 'The diagram JSON is invalid. Please check the JSON and try again. Need help? chartdb.io@gmail.com', + }, + }, relationship_type: { one_to_one: 'Un à Un', one_to_many: 'Un à Plusieurs', diff --git a/src/i18n/locales/hi.ts b/src/i18n/locales/hi.ts index ef1304dce..52b487d1d 100644 --- a/src/i18n/locales/hi.ts +++ b/src/i18n/locales/hi.ts @@ -32,6 +32,12 @@ export const hi: LanguageTranslation = { show_dependencies: 'निर्भरता दिखाएँ', hide_dependencies: 'निर्भरता छिपाएँ', }, + // TODO: Translate + share: { + share: 'Share', + export_diagram: 'Export Diagram', + import_diagram: 'Import Diagram', + }, help: { help: 'मदद', visit_website: 'ChartDB वेबसाइट पर जाएँ', @@ -228,6 +234,8 @@ export const hi: LanguageTranslation = { cancel: 'रद्द करें', back: 'वापस', + // TODO: Translate + import_from_file: 'Import from File', empty_diagram: 'खाली आरेख', continue: 'जारी रखें', import: 'आयात करें', @@ -331,7 +339,26 @@ export const hi: LanguageTranslation = { close: 'अभी नहीं', confirm: 'बिलकुल!', }, - + // TODO: Translate + export_diagram_dialog: { + title: 'Export Diagram', + description: 'Choose the format for export:', + format_json: 'JSON', + cancel: 'Cancel', + export: 'Export', + }, + // TODO: Translate + import_diagram_dialog: { + title: 'Import Diagram', + description: 'Paste the diagram JSON below:', + cancel: 'Cancel', + import: 'Import', + error: { + title: 'Error importing diagram', + description: + 'The diagram JSON is invalid. Please check the JSON and try again. Need help? chartdb.io@gmail.com', + }, + }, relationship_type: { one_to_one: 'एक से एक', one_to_many: 'एक से कई', diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 17e4567f0..002fcc40d 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -33,6 +33,12 @@ export const ja: LanguageTranslation = { show_dependencies: 'Show Dependencies', hide_dependencies: 'Hide Dependencies', }, + // TODO: Translate + share: { + share: 'Share', + export_diagram: 'Export Diagram', + import_diagram: 'Import Diagram', + }, help: { help: 'ヘルプ', visit_website: 'ChartDBにアクセス', @@ -230,6 +236,8 @@ export const ja: LanguageTranslation = { cancel: 'キャンセル', back: '戻る', + // TODO: Translate + import_from_file: 'Import from File', empty_diagram: '空のダイアグラム', continue: '続行', import: 'インポート', @@ -333,7 +341,26 @@ export const ja: LanguageTranslation = { close: '今はしない', confirm: 'もちろん!', }, - + // TODO: Translate + export_diagram_dialog: { + title: 'Export Diagram', + description: 'Choose the format for export:', + format_json: 'JSON', + cancel: 'Cancel', + export: 'Export', + }, + // TODO: Translate + import_diagram_dialog: { + title: 'Import Diagram', + description: 'Paste the diagram JSON below:', + cancel: 'Cancel', + import: 'Import', + error: { + title: 'Error importing diagram', + description: + 'The diagram JSON is invalid. Please check the JSON and try again. Need help? chartdb.io@gmail.com', + }, + }, relationship_type: { one_to_one: '1対1', one_to_many: '1対多', diff --git a/src/i18n/locales/ko_KR.ts b/src/i18n/locales/ko_KR.ts index 0ba8c5bac..f60c1d130 100644 --- a/src/i18n/locales/ko_KR.ts +++ b/src/i18n/locales/ko_KR.ts @@ -32,6 +32,12 @@ export const ko_KR: LanguageTranslation = { show_dependencies: '종속성 보이기', hide_dependencies: '종속성 숨기기', }, + // TODO: Translate + share: { + share: 'Share', + export_diagram: 'Export Diagram', + import_diagram: 'Import Diagram', + }, help: { help: '도움말', visit_website: 'ChartDB 사이트 방문', @@ -225,6 +231,8 @@ export const ko_KR: LanguageTranslation = { cancel: '취소', back: '뒤로가기', + // TODO: Translate + import_from_file: 'Import from File', empty_diagram: '빈 다이어그램으로 시작', continue: '계속', import: '가져오기', @@ -327,7 +335,26 @@ export const ko_KR: LanguageTranslation = { close: '아직은 괜찮아요', confirm: '당연하죠!', }, - + // TODO: Translate + export_diagram_dialog: { + title: 'Export Diagram', + description: 'Choose the format for export:', + format_json: 'JSON', + cancel: 'Cancel', + export: 'Export', + }, + // TODO: Translate + import_diagram_dialog: { + title: 'Import Diagram', + description: 'Paste the diagram JSON below:', + cancel: 'Cancel', + import: 'Import', + error: { + title: 'Error importing diagram', + description: + 'The diagram JSON is invalid. Please check the JSON and try again. Need help? chartdb.io@gmail.com', + }, + }, relationship_type: { one_to_one: '일대일 (1:1)', one_to_many: '일대다 (1:N)', diff --git a/src/i18n/locales/pt_BR.ts b/src/i18n/locales/pt_BR.ts index f6c693d3d..a27cfda47 100644 --- a/src/i18n/locales/pt_BR.ts +++ b/src/i18n/locales/pt_BR.ts @@ -32,6 +32,12 @@ export const pt_BR: LanguageTranslation = { show_dependencies: 'Mostrar Dependências', hide_dependencies: 'Ocultar Dependências', }, + // TODO: Translate + share: { + share: 'Share', + export_diagram: 'Export Diagram', + import_diagram: 'Import Diagram', + }, help: { help: 'Ajuda', visit_website: 'Visitar ChartDB', @@ -225,6 +231,8 @@ export const pt_BR: LanguageTranslation = { cancel: 'Cancelar', back: 'Voltar', + // TODO: Translate + import_from_file: 'Import from File', empty_diagram: 'Diagrama vazio', continue: 'Continuar', import: 'Importar', @@ -328,7 +336,26 @@ export const pt_BR: LanguageTranslation = { close: 'Agora não', confirm: 'Claro!', }, - + // TODO: Translate + export_diagram_dialog: { + title: 'Export Diagram', + description: 'Choose the format for export:', + format_json: 'JSON', + cancel: 'Cancel', + export: 'Export', + }, + // TODO: Translate + import_diagram_dialog: { + title: 'Import Diagram', + description: 'Paste the diagram JSON below:', + cancel: 'Cancel', + import: 'Import', + error: { + title: 'Error importing diagram', + description: + 'The diagram JSON is invalid. Please check the JSON and try again. Need help? chartdb.io@gmail.com', + }, + }, relationship_type: { one_to_one: 'Um para Um', one_to_many: 'Um para Muitos', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index e1817ae6e..75f3269cc 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -32,6 +32,12 @@ export const ru: LanguageTranslation = { show_dependencies: 'Показать зависимости', hide_dependencies: 'Скрыть зависимости', }, + // TODO: Translate + share: { + share: 'Share', + export_diagram: 'Export Diagram', + import_diagram: 'Import Diagram', + }, help: { help: 'Помощь', visit_website: 'Перейти на сайт ChartDB', @@ -223,6 +229,8 @@ export const ru: LanguageTranslation = { cancel: 'Отменить', back: 'Назад', + // TODO: Translate + import_from_file: 'Import from File', empty_diagram: 'Пустая диаграмма', continue: 'Продолжить', import: 'Импорт', @@ -327,7 +335,26 @@ export const ru: LanguageTranslation = { close: 'Не сейчас', confirm: 'Конечно!', }, - + // TODO: Translate + export_diagram_dialog: { + title: 'Export Diagram', + description: 'Choose the format for export:', + format_json: 'JSON', + cancel: 'Cancel', + export: 'Export', + }, + // TODO: Translate + import_diagram_dialog: { + title: 'Import Diagram', + description: 'Paste the diagram JSON below:', + cancel: 'Cancel', + import: 'Import', + error: { + title: 'Error importing diagram', + description: + 'The diagram JSON is invalid. Please check the JSON and try again. Need help? chartdb.io@gmail.com', + }, + }, relationship_type: { one_to_one: 'Один к одному', one_to_many: 'Один ко многим', diff --git a/src/i18n/locales/uk.ts b/src/i18n/locales/uk.ts index 8ebf99f8d..963e31019 100644 --- a/src/i18n/locales/uk.ts +++ b/src/i18n/locales/uk.ts @@ -32,6 +32,12 @@ export const uk: LanguageTranslation = { show_dependencies: 'Показати залежності', hide_dependencies: 'Приховати залежності', }, + // TODO: Translate + share: { + share: 'Share', + export_diagram: 'Export Diagram', + import_diagram: 'Import Diagram', + }, help: { help: 'Допомога', visit_website: 'Відвідайте ChartDB', @@ -225,6 +231,8 @@ export const uk: LanguageTranslation = { cancel: 'Скасувати', back: 'Назад', + // TODO: Translate + import_from_file: 'Import from File', empty_diagram: 'Порожня діаграма', continue: 'Продовжити', import: 'Імпорт', @@ -328,7 +336,26 @@ export const uk: LanguageTranslation = { close: 'Не зараз', confirm: 'звичайно!', }, - + // TODO: Translate + export_diagram_dialog: { + title: 'Export Diagram', + description: 'Choose the format for export:', + format_json: 'JSON', + cancel: 'Cancel', + export: 'Export', + }, + // TODO: Translate + import_diagram_dialog: { + title: 'Import Diagram', + description: 'Paste the diagram JSON below:', + cancel: 'Cancel', + import: 'Import', + error: { + title: 'Error importing diagram', + description: + 'The diagram JSON is invalid. Please check the JSON and try again. Need help? chartdb.io@gmail.com', + }, + }, relationship_type: { one_to_one: 'Один до одного', one_to_many: 'Один до багатьох', diff --git a/src/lib/data/data-types/data-types.ts b/src/lib/data/data-types/data-types.ts index c047661ea..6710224b4 100644 --- a/src/lib/data/data-types/data-types.ts +++ b/src/lib/data/data-types/data-types.ts @@ -1,3 +1,4 @@ +import { z } from 'zod'; import { DatabaseType } from '../../domain/database-type'; import { genericDataTypes } from './generic-data-types'; import { mariadbDataTypes } from './mariadb-data-types'; @@ -11,6 +12,11 @@ export interface DataType { name: string; } +export const dataTypeSchema: z.ZodType = z.object({ + id: z.string(), + name: z.string(), +}); + export const dataTypeMap: Record = { [DatabaseType.GENERIC]: genericDataTypes, [DatabaseType.POSTGRESQL]: postgresDataTypes, diff --git a/src/lib/domain/db-dependency.ts b/src/lib/domain/db-dependency.ts index f043a138b..ad977541d 100644 --- a/src/lib/domain/db-dependency.ts +++ b/src/lib/domain/db-dependency.ts @@ -1,3 +1,4 @@ +import { z } from 'zod'; import type { ViewInfo } from '../data/import-metadata/metadata-types/view-info'; import { DatabaseType } from './database-type'; import { @@ -17,6 +18,15 @@ export interface DBDependency { createdAt: number; } +export const dbDependencySchema: z.ZodType = z.object({ + id: z.string(), + schema: z.string().optional(), + tableId: z.string(), + dependentSchema: z.string().optional(), + dependentTableId: z.string(), + createdAt: z.number(), +}); + export const shouldShowDependencyBySchemaFilter = ( dependency: DBDependency, filteredSchemas?: string[] diff --git a/src/lib/domain/db-field.ts b/src/lib/domain/db-field.ts index 7dcf0dabd..3956aa7e0 100644 --- a/src/lib/domain/db-field.ts +++ b/src/lib/domain/db-field.ts @@ -1,4 +1,5 @@ -import type { DataType } from '../data/data-types/data-types'; +import { z } from 'zod'; +import { dataTypeSchema, type DataType } from '../data/data-types/data-types'; import type { ColumnInfo } from '../data/import-metadata/metadata-types/column-info'; import type { AggregatedIndexInfo } from '../data/import-metadata/metadata-types/index-info'; import type { PrimaryKeyInfo } from '../data/import-metadata/metadata-types/primary-key-info'; @@ -22,6 +23,22 @@ export interface DBField { comments?: string; } +export const dbFieldSchema: z.ZodType = z.object({ + id: z.string(), + name: z.string(), + type: dataTypeSchema, + primaryKey: z.boolean(), + unique: z.boolean(), + nullable: z.boolean(), + createdAt: z.number(), + characterMaximumLength: z.string().optional(), + precision: z.number().optional(), + scale: z.number().optional(), + default: z.string().optional(), + collation: z.string().optional(), + comments: z.string().optional(), +}); + export const createFieldsFromMetadata = ({ columns, tableSchema, diff --git a/src/lib/domain/db-index.ts b/src/lib/domain/db-index.ts index 7d02346e6..d909fb4cd 100644 --- a/src/lib/domain/db-index.ts +++ b/src/lib/domain/db-index.ts @@ -1,3 +1,4 @@ +import { z } from 'zod'; import type { AggregatedIndexInfo } from '../data/import-metadata/metadata-types/index-info'; import { generateId } from '../utils'; import type { DBField } from './db-field'; @@ -10,6 +11,14 @@ export interface DBIndex { createdAt: number; } +export const dbIndexSchema: z.ZodType = z.object({ + id: z.string(), + name: z.string(), + unique: z.boolean(), + fieldIds: z.array(z.string()), + createdAt: z.number(), +}); + export const createIndexesFromMetadata = ({ aggregatedIndexes, fields, diff --git a/src/lib/domain/db-relationship.ts b/src/lib/domain/db-relationship.ts index b9e232da5..d5db09868 100644 --- a/src/lib/domain/db-relationship.ts +++ b/src/lib/domain/db-relationship.ts @@ -1,3 +1,4 @@ +import { z } from 'zod'; import type { ForeignKeyInfo } from '../data/import-metadata/metadata-types/foreign-key-info'; import type { DBField } from './db-field'; import { @@ -21,6 +22,20 @@ export interface DBRelationship { createdAt: number; } +export const dbRelationshipSchema: z.ZodType = z.object({ + id: z.string(), + name: z.string(), + sourceSchema: z.string().optional(), + sourceTableId: z.string(), + targetSchema: z.string().optional(), + targetTableId: z.string(), + sourceFieldId: z.string(), + targetFieldId: z.string(), + sourceCardinality: z.union([z.literal('one'), z.literal('many')]), + targetCardinality: z.union([z.literal('one'), z.literal('many')]), + createdAt: z.number(), +}); + export type RelationshipType = | 'one_to_one' | 'one_to_many' diff --git a/src/lib/domain/db-table.ts b/src/lib/domain/db-table.ts index e49a66cf6..c106a40ca 100644 --- a/src/lib/domain/db-table.ts +++ b/src/lib/domain/db-table.ts @@ -1,5 +1,13 @@ -import { createIndexesFromMetadata, type DBIndex } from './db-index'; -import { createFieldsFromMetadata, type DBField } from './db-field'; +import { + createIndexesFromMetadata, + dbIndexSchema, + type DBIndex, +} from './db-index'; +import { + createFieldsFromMetadata, + dbFieldSchema, + type DBField, +} from './db-field'; import type { TableInfo } from '../data/import-metadata/metadata-types/table-info'; import { createAggregatedIndexes } from '../data/import-metadata/metadata-types/index-info'; import { materializedViewColor, viewColor, randomColor } from '@/lib/colors'; @@ -16,6 +24,7 @@ import { } from './db-schema'; import { DatabaseType } from './database-type'; import type { DatabaseMetadata } from '../data/import-metadata/metadata-types/database-metadata'; +import { z } from 'zod'; export interface DBTable { id: string; @@ -34,6 +43,23 @@ export interface DBTable { hidden?: boolean; } +export const dbTableSchema: z.ZodType = z.object({ + id: z.string(), + name: z.string(), + schema: z.string().optional(), + x: z.number(), + y: z.number(), + fields: z.array(dbFieldSchema), + indexes: z.array(dbIndexSchema), + color: z.string(), + isView: z.boolean(), + isMaterializedView: z.boolean().optional(), + createdAt: z.number(), + width: z.number().optional(), + comments: z.string().optional(), + hidden: z.boolean().optional(), +}); + export const shouldShowTablesBySchemaFilter = ( table: DBTable, filteredSchemas?: string[] diff --git a/src/lib/domain/diagram.ts b/src/lib/domain/diagram.ts index 3c69d66d4..347f6f61d 100644 --- a/src/lib/domain/diagram.ts +++ b/src/lib/domain/diagram.ts @@ -1,12 +1,23 @@ +import { z } from 'zod'; import type { DatabaseMetadata } from '../data/import-metadata/metadata-types/database-metadata'; -import type { DatabaseEdition } from './database-edition'; +import { DatabaseEdition } from './database-edition'; import { DatabaseType } from './database-type'; import type { DBDependency } from './db-dependency'; -import { createDependenciesFromMetadata } from './db-dependency'; +import { + createDependenciesFromMetadata, + dbDependencySchema, +} from './db-dependency'; import type { DBRelationship } from './db-relationship'; -import { createRelationshipsFromMetadata } from './db-relationship'; +import { + createRelationshipsFromMetadata, + dbRelationshipSchema, +} from './db-relationship'; import type { DBTable } from './db-table'; -import { adjustTablePositions, createTablesFromMetadata } from './db-table'; +import { + adjustTablePositions, + createTablesFromMetadata, + dbTableSchema, +} from './db-table'; import { generateDiagramId } from '@/lib/utils'; export interface Diagram { id: string; @@ -20,6 +31,18 @@ export interface Diagram { updatedAt: Date; } +export const diagramSchema: z.ZodType = z.object({ + id: z.string(), + name: z.string(), + databaseType: z.nativeEnum(DatabaseType), + databaseEdition: z.nativeEnum(DatabaseEdition).optional(), + tables: z.array(dbTableSchema).optional(), + relationships: z.array(dbRelationshipSchema).optional(), + dependencies: z.array(dbDependencySchema).optional(), + createdAt: z.date(), + updatedAt: z.date(), +}); + export const loadFromDatabaseMetadata = async ({ databaseType, databaseMetadata, diff --git a/src/lib/export-import-utils.ts b/src/lib/export-import-utils.ts new file mode 100644 index 000000000..1af8bc049 --- /dev/null +++ b/src/lib/export-import-utils.ts @@ -0,0 +1,33 @@ +import { generateId } from 'ai'; +import { diagramSchema, type Diagram } from './domain/diagram'; +import { cloneDiagram, generateDiagramId } from './utils'; + +export const runningIdGenerator = (): (() => string) => { + let id = 0; + return () => (id++).toString(); +}; + +const cloneDiagramWithRunningIds = (diagram: Diagram) => + cloneDiagram(diagram, runningIdGenerator()); + +const cloneDiagramWithIds = (diagram: Diagram): Diagram => ({ + ...cloneDiagram(diagram, generateId), + id: generateDiagramId(), +}); + +export const diagramToJSONOutput = (diagram: Diagram): string => { + const clonedDiagram = cloneDiagramWithRunningIds(diagram); + return JSON.stringify(clonedDiagram, null, 2); +}; + +export const diagramFromJSONInput = (json: string): Diagram => { + const loadedDiagram = JSON.parse(json); + + const diagram = diagramSchema.parse({ + ...loadedDiagram, + createdAt: new Date(), + updatedAt: new Date(), + }); + + return cloneDiagramWithIds(diagram); +}; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 56a8fb5bb..f9e238b76 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,12 @@ import { type ClassValue, clsx } from 'clsx'; import { customAlphabet } from 'nanoid'; import { twMerge } from 'tailwind-merge'; +import type { Diagram } from './domain/diagram'; +import type { DBTable } from './domain/db-table'; +import type { DBField } from './domain/db-field'; +import type { DBIndex } from './domain/db-index'; +import type { DBRelationship } from './domain/db-relationship'; +import type { DBDependency } from './domain/db-dependency'; const randomId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 25); const UUID_KEY = 'uuid'; @@ -88,3 +94,89 @@ export const decodeBase64ToUtf8 = (base64: string) => { export const waitFor = async (ms: number): Promise => { return new Promise((resolve) => setTimeout(resolve, ms)); }; + +export const cloneDiagram = ( + diagram: Diagram, + generateId: () => string +): Diagram => { + const diagramId = generateId(); + + const idsMap = new Map(); + diagram.tables?.forEach((table) => { + idsMap.set(table.id, generateId()); + + table.fields.forEach((field) => { + idsMap.set(field.id, generateId()); + }); + + table.indexes.forEach((index) => { + idsMap.set(index.id, generateId()); + }); + }); + diagram.relationships?.forEach((relationship) => { + idsMap.set(relationship.id, generateId()); + }); + + diagram.dependencies?.forEach((dependency) => { + idsMap.set(dependency.id, generateId()); + }); + + const getNewId = (id: string) => { + const newId = idsMap.get(id); + if (!newId) { + throw new Error(`Id not found for ${id}`); + } + return newId; + }; + + const tables: DBTable[] = + diagram.tables?.map((table) => { + const newTable: DBTable = { ...table, id: getNewId(table.id) }; + newTable.fields = table.fields.map( + (field): DBField => ({ + ...field, + id: getNewId(field.id), + }) + ); + newTable.indexes = table.indexes.map( + (index): DBIndex => ({ + ...index, + id: getNewId(index.id), + }) + ); + + return newTable; + }) ?? []; + + const relationships: DBRelationship[] = + diagram.relationships?.map( + (relationship): DBRelationship => ({ + ...relationship, + id: getNewId(relationship.id), + sourceTableId: getNewId(relationship.sourceTableId), + targetTableId: getNewId(relationship.targetTableId), + sourceFieldId: getNewId(relationship.sourceFieldId), + targetFieldId: getNewId(relationship.targetFieldId), + }) + ) ?? []; + + const dependencies: DBDependency[] = + diagram.dependencies?.map( + (dependency): DBDependency => ({ + ...dependency, + id: getNewId(dependency.id), + dependentTableId: getNewId(dependency.dependentTableId), + tableId: getNewId(dependency.tableId), + }) + ) ?? []; + + return { + ...diagram, + id: diagramId, + dependencies, + relationships, + tables, + createdAt: new Date(), + updatedAt: new Date(), + }; +}; diff --git a/src/pages/editor-page/top-navbar/top-navbar.tsx b/src/pages/editor-page/top-navbar/top-navbar.tsx index e04b1d0a2..2e15cebe5 100644 --- a/src/pages/editor-page/top-navbar/top-navbar.tsx +++ b/src/pages/editor-page/top-navbar/top-navbar.tsx @@ -48,6 +48,8 @@ export const TopNavbar: React.FC = () => { openImportDatabaseDialog, showAlert, openExportImageDialog, + openExportDiagramDialog, + openImportDiagramDialog, } = useDialog(); const { setTheme, theme } = useTheme(); const { hideSidePanel, isSidePanelShowed, showSidePanel } = useLayout(); @@ -204,9 +206,9 @@ export const TopNavbar: React.FC = () => { ); return ( - diff --git a/src/templates-data/template-utils.ts b/src/templates-data/template-utils.ts index 2b77fe5f0..9cfcd3fff 100644 --- a/src/templates-data/template-utils.ts +++ b/src/templates-data/template-utils.ts @@ -1,90 +1,16 @@ import type { Diagram } from '@/lib/domain/diagram'; import type { Template } from './templates-data'; -import { generateId, removeDups } from '@/lib/utils'; -import type { DBTable } from '@/lib/domain/db-table'; -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 { DBDependency } from '@/lib/domain/db-dependency'; +import { cloneDiagram, generateId, removeDups } from '@/lib/utils'; export const convertTemplateToNewDiagram = (template: Template): Diagram => { - // const diagramId = generateDiagramId(); const diagramId = template.diagram.id; - const idsMap = new Map(); - template.diagram.tables?.forEach((table) => { - idsMap.set(table.id, generateId()); - - table.fields.forEach((field) => { - idsMap.set(field.id, generateId()); - }); - - table.indexes.forEach((index) => { - idsMap.set(index.id, generateId()); - }); - }); - template.diagram.relationships?.forEach((relationship) => { - idsMap.set(relationship.id, generateId()); - }); - - template.diagram.dependencies?.forEach((dependency) => { - idsMap.set(dependency.id, generateId()); - }); - - const getNewId = (id: string) => { - const newId = idsMap.get(id); - if (!newId) { - throw new Error(`Id not found for ${id}`); - } - return newId; - }; - - const tables: DBTable[] = - template.diagram.tables?.map((table) => { - const newTable: DBTable = { ...table, id: getNewId(table.id) }; - newTable.fields = table.fields.map( - (field): DBField => ({ - ...field, - id: getNewId(field.id), - }) - ); - newTable.indexes = table.indexes.map( - (index): DBIndex => ({ - ...index, - id: getNewId(index.id), - }) - ); - return newTable; - }) ?? []; - - const relationships: DBRelationship[] = - template.diagram.relationships?.map( - (relationship): DBRelationship => ({ - ...relationship, - id: getNewId(relationship.id), - sourceTableId: getNewId(relationship.sourceTableId), - targetTableId: getNewId(relationship.targetTableId), - sourceFieldId: getNewId(relationship.sourceFieldId), - targetFieldId: getNewId(relationship.targetFieldId), - }) - ) ?? []; - - const dependencies: DBDependency[] = - template.diagram.dependencies?.map( - (dependency): DBDependency => ({ - ...dependency, - id: getNewId(dependency.id), - dependentTableId: getNewId(dependency.dependentTableId), - tableId: getNewId(dependency.tableId), - }) - ) ?? []; + const clonedDiagram = cloneDiagram(template.diagram, generateId); return { ...template.diagram, + ...clonedDiagram, id: diagramId, - dependencies, - relationships, - tables, }; };