Compare commits
	
		
			130 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 8102f19f79 | ||
|  | 840a00ebcd | ||
|  | 181f96d250 | ||
|  | ce2389f135 | ||
|  | f15dc77f33 | ||
|  | caa81c24a6 | ||
|  | e3cb62788c | ||
|  | fc46cbb893 | ||
|  | d94a71e9e1 | ||
|  | cf81253535 | ||
|  | 25c4b42538 | ||
|  | f7a6e0cb5e | ||
|  | 85275e5dd6 | ||
|  | 4e5b467ce5 | ||
|  | 874aa5ab75 | ||
|  | 0940d72d5d | ||
|  | 0d1739d70f | ||
|  | 60fe0843ac | ||
|  | 794f226209 | ||
|  | 2fbf3476b8 | ||
|  | 897ac60a82 | ||
|  | 18f228ca1d | ||
|  | 14de30b7aa | ||
|  | 3faa39e787 | ||
|  | 63b5ba0bb9 | ||
|  | 44eac7daff | ||
|  | 502472b083 | ||
|  | 52d2ea596c | ||
|  | bd67ccfbcf | ||
|  | 62beb68fa1 | ||
|  | 09b1275475 | ||
|  | 5dd7fe75d1 | ||
|  | 2939320a15 | ||
|  | a643852837 | ||
|  | 467ff697c9 | ||
|  | d6919f3033 | ||
|  | 56382a9fdc | ||
|  | e06eb2a48e | ||
|  | 543b716c77 | ||
|  | b55d631146 | ||
|  | ef118929ad | ||
|  | 68f48190c9 | ||
|  | bba265ad43 | ||
|  | cbc4e85a14 | ||
|  | 26a0a5b550 | ||
|  | b935b7f251 | ||
|  | a1c0cf102a | ||
|  | ab89bad6d5 | ||
|  | deb218423f | ||
|  | 48342471ac | ||
|  | 47bb87a88f | ||
|  | a96c2e1078 | ||
|  | 26d95eed25 | ||
|  | be65328f24 | ||
|  | 85fd14fa02 | ||
|  | 9c485b3b01 | ||
|  | e993f1549c | ||
|  | 0db67ea42a | ||
|  | b9e621bd68 | ||
|  | 93d59f8887 | ||
|  | 190e4f4ffa | ||
|  | dc404c9d7e | ||
|  | dd4324d64f | ||
|  | 1878083056 | ||
|  | 7b6271962a | ||
|  | 2edc8dfde8 | ||
|  | 004d530880 | ||
|  | fd2cc9fcfc | ||
|  | 4c93326bb6 | ||
|  | ef3d7a8b67 | ||
|  | 3b3be086b1 | ||
|  | b424518212 | ||
|  | 99a8201398 | ||
|  | eb9b41e4f6 | ||
|  | fef6d3f499 | ||
|  | 14f11c27a7 | ||
|  | 2118bce0f0 | ||
|  | 88be6c1fd4 | ||
|  | 0dcc9b9568 | ||
|  | ff3269ec05 | ||
|  | 659dc2e3e7 | ||
|  | c36cd33180 | ||
|  | 58231c9139 | ||
|  | 1643e7bdeb | ||
|  | 42d4cbac8c | ||
|  | 7452ca6965 | ||
|  | 27aede7794 | ||
|  | e9e2736cb2 | ||
|  | 74c1730425 | ||
|  | 94bed7fcce | ||
|  | 8abf2a7bfc | ||
|  | ee659eaa03 | ||
|  | 7c5db0848e | ||
|  | 4b43f720e9 | ||
|  | 766b5164b8 | ||
|  | 7868ca9f42 | ||
|  | 0411742864 | ||
|  | 9831ac5a10 | ||
|  | 91c6fb9249 | ||
|  | c155013668 | ||
|  | 1b0f293c87 | ||
|  | df2dc03aa0 | ||
|  | 205d431c89 | ||
|  | 0abe18cdf9 | ||
|  | a151f56b5d | ||
|  | 2b6b733261 | ||
|  | b56b04925c | ||
|  | 635fb53c9f | ||
|  | d6659795bc | ||
|  | 348f80568e | ||
|  | 5f9c74a9ad | ||
|  | 5409288388 | ||
|  | 2309306ef5 | ||
|  | 3574cecc7c | ||
|  | 29b8edc051 | ||
|  | 5fc10a7e64 | ||
|  | 807cd22e0c | ||
|  | 03772f6b4f | ||
|  | 885eb719de | ||
|  | 94656ec7a5 | ||
|  | a0e966b64f | ||
|  | a8fe491c1b | ||
|  | ddeef3b134 | ||
|  | d45677e92d | ||
|  | 9c7d03c285 | ||
|  | be1b109f23 | ||
|  | 05eaf85a3d | ||
|  | 53f443d452 | ||
|  | 134c62f931 | ||
|  | 4bb4766e1a | 
| @@ -1,29 +0,0 @@ | ||||
| module.exports = { | ||||
|     root: true, | ||||
|     env: { browser: true, es2020: true }, | ||||
|     extends: [ | ||||
|         'eslint:recommended', | ||||
|         'plugin:react/recommended', | ||||
|         'plugin:@typescript-eslint/recommended', | ||||
|         'plugin:react-hooks/recommended', | ||||
|         'plugin:css-modules/recommended', | ||||
|         'plugin:tailwindcss/recommended', | ||||
|         'plugin:prettier/recommended', | ||||
|         // 'plugin:jsx-a11y/recommended', | ||||
|     ], | ||||
|     ignorePatterns: ['dist', '.eslintrc.cjs'], | ||||
|     parser: '@typescript-eslint/parser', | ||||
|     plugins: ['react-refresh', 'css-modules', 'tailwindcss', 'jsx-a11y'], | ||||
|     rules: { | ||||
|         '@typescript-eslint/consistent-type-imports': 'error', | ||||
|         'react-refresh/only-export-components': [ | ||||
|             'warn', | ||||
|             { allowConstantExport: true }, | ||||
|         ], | ||||
|         'react/no-unescaped-entities': 'off', | ||||
|         'react/prop-types': 'off', | ||||
|     }, | ||||
|     settings: { | ||||
|         react: { version: 'detect' }, | ||||
|     }, | ||||
| }; | ||||
							
								
								
									
										11
									
								
								.github/workflows/publish.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -32,7 +32,7 @@ jobs: | ||||
|           registry: ${{ env.REGISTRY }} | ||||
|           username: ${{ github.actor }} | ||||
|           password: ${{ secrets.GITHUB_TOKEN }} | ||||
|      | ||||
|  | ||||
|       - name: Install dependencies | ||||
|         run: npm ci | ||||
|  | ||||
| @@ -42,6 +42,12 @@ jobs: | ||||
|       - name: Build project | ||||
|         run: npm run build | ||||
|  | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v3 | ||||
|  | ||||
|       - name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v3 | ||||
|  | ||||
|       - name: Extract metadata (tags, labels) for Docker | ||||
|         id: meta | ||||
|         uses: docker/metadata-action@v4 | ||||
| @@ -50,10 +56,11 @@ jobs: | ||||
|           tags: | | ||||
|             type=semver,pattern={{version}} | ||||
|  | ||||
|       - name: Build and push Docker image | ||||
|       - name: Build and push multi-arch Docker image | ||||
|         uses: docker/build-push-action@v6 | ||||
|         with: | ||||
|           context: . | ||||
|           push: true | ||||
|           platforms: linux/amd64,linux/arm64 | ||||
|           tags: ${{ steps.meta.outputs.tags }} | ||||
|           labels: ${{ steps.meta.outputs.labels }} | ||||
|   | ||||
							
								
								
									
										197
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						| @@ -1,5 +1,202 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## [1.11.0](https://github.com/chartdb/chartdb/compare/v1.10.0...v1.11.0) (2025-04-17) | ||||
|  | ||||
|  | ||||
| ### Features | ||||
|  | ||||
| * add sidebar footer help buttons ([#650](https://github.com/chartdb/chartdb/issues/650)) ([fc46cbb](https://github.com/chartdb/chartdb/commit/fc46cbb8933761c7bac3604664f7de812f6f5b6b)) | ||||
| * **import-sql:** import postgresql via SQL (DDL script) ([#639](https://github.com/chartdb/chartdb/issues/639)) ([f7a6e0c](https://github.com/chartdb/chartdb/commit/f7a6e0cb5e4921dd9540739f9da269858e7ca7be)) | ||||
|  | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| * **import:** display query result formatted ([#644](https://github.com/chartdb/chartdb/issues/644)) ([caa81c2](https://github.com/chartdb/chartdb/commit/caa81c24a6535bc87129c38622aac5a62a6d479d)) | ||||
| * **import:** strict parse of database metadata ([#635](https://github.com/chartdb/chartdb/issues/635)) ([0940d72](https://github.com/chartdb/chartdb/commit/0940d72d5d3726650213257639f24ba47e729854)) | ||||
| * **mobile:** fix create diagram modal on mobile ([#646](https://github.com/chartdb/chartdb/issues/646)) ([25c4b42](https://github.com/chartdb/chartdb/commit/25c4b4253849575d7a781ed197281e2a35e7184a)) | ||||
| * **mysql-ddl:** update the script to import - for create fks ([#642](https://github.com/chartdb/chartdb/issues/642)) ([cf81253](https://github.com/chartdb/chartdb/commit/cf81253535ca5a3b8a65add78287c1bdb283a1c7)) | ||||
| * **performance:** Import deps dynamically ([#652](https://github.com/chartdb/chartdb/issues/652)) ([e3cb627](https://github.com/chartdb/chartdb/commit/e3cb62788c13f149e35e1a5020191bd43d14b52f)) | ||||
| * remove unused links from help menu ([#623](https://github.com/chartdb/chartdb/issues/623)) ([85275e5](https://github.com/chartdb/chartdb/commit/85275e5dd6e7845f06f682eeceda7932fc87e875)) | ||||
| * **sidebar:** turn sidebar to responsive for mobile ([#658](https://github.com/chartdb/chartdb/issues/658)) ([ce2389f](https://github.com/chartdb/chartdb/commit/ce2389f135d399d82c9848335d31174bac8a3791)) | ||||
|  | ||||
| ## [1.10.0](https://github.com/chartdb/chartdb/compare/v1.9.0...v1.10.0) (2025-03-25) | ||||
|  | ||||
|  | ||||
| ### Features | ||||
|  | ||||
| * **cloudflare-d1:** add support to cloudflare-d1 + wrangler cli ([#632](https://github.com/chartdb/chartdb/issues/632)) ([794f226](https://github.com/chartdb/chartdb/commit/794f2262092fbe36e27e92220221ed98cb51ae37)) | ||||
|  | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| * **dbml-editor:** dealing with dbml editor for non-generic db-type ([#624](https://github.com/chartdb/chartdb/issues/624)) ([14de30b](https://github.com/chartdb/chartdb/commit/14de30b7aaa0ccaca8372f0213b692266d53f0de)) | ||||
| * **export-sql:** move from AI sql-export for MySQL&MariaDB to deterministic script ([#628](https://github.com/chartdb/chartdb/issues/628)) ([2fbf347](https://github.com/chartdb/chartdb/commit/2fbf3476b87f1177af17de8242a74d195dae5f35)) | ||||
| * **export-sql:** move from AI sql-export for postgres to deterministic script ([#626](https://github.com/chartdb/chartdb/issues/626)) ([18f228c](https://github.com/chartdb/chartdb/commit/18f228ca1d5a6c6056cb7c3bfc24d04ec470edf1)) | ||||
| * **export-sql:** move from AI sql-export for sqlite to deterministic script ([#627](https://github.com/chartdb/chartdb/issues/627)) ([897ac60](https://github.com/chartdb/chartdb/commit/897ac60a829a00e9453d670cceeb2282e9e93f1c)) | ||||
| * **sidebar:** add sidebar for diagram objects ([#618](https://github.com/chartdb/chartdb/issues/618)) ([63b5ba0](https://github.com/chartdb/chartdb/commit/63b5ba0bb9934c4e5c5d0d1b6f995afbbd3acf36)) | ||||
| * **sidebar:** opens sidepanel in case its closed and click on sidebar ([#620](https://github.com/chartdb/chartdb/issues/620)) ([3faa39e](https://github.com/chartdb/chartdb/commit/3faa39e7875d836dfe526d94a10f8aed070ac1c1)) | ||||
|  | ||||
| ## [1.9.0](https://github.com/chartdb/chartdb/compare/v1.8.1...v1.9.0) (2025-03-13) | ||||
|  | ||||
|  | ||||
| ### Features | ||||
|  | ||||
| * **canvas:** highlight the Show-All button when No-Tables are visible in the canvas ([#612](https://github.com/chartdb/chartdb/issues/612)) ([62beb68](https://github.com/chartdb/chartdb/commit/62beb68fa1ec22ccd4fe5e59a8ceb9d3e8f6d374)) | ||||
| * **chart max length:** add support for edit char max length ([#613](https://github.com/chartdb/chartdb/issues/613)) ([09b1275](https://github.com/chartdb/chartdb/commit/09b12754757b9625ca287d91a92cf0d83c9e2b89)) | ||||
| * **chart max length:** enable edit length from data type select box ([#616](https://github.com/chartdb/chartdb/issues/616)) ([bd67ccf](https://github.com/chartdb/chartdb/commit/bd67ccfbcf66b919453ca6c0bfd71e16772b3d8e)) | ||||
|  | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| * **cardinality:** set true as default ([#583](https://github.com/chartdb/chartdb/issues/583)) ([2939320](https://github.com/chartdb/chartdb/commit/2939320a15a9ccd9eccfe46c26e04ca1edca2420)) | ||||
| * **performance:** Optimize performance of field comments editing ([#610](https://github.com/chartdb/chartdb/issues/610)) ([5dd7fe7](https://github.com/chartdb/chartdb/commit/5dd7fe75d1b0378ba406c75183c5e2356730c3b4)) | ||||
| * remove Buckle dialog ([#617](https://github.com/chartdb/chartdb/issues/617)) ([502472b](https://github.com/chartdb/chartdb/commit/502472b08342be425e66e2b6c94e5fe37ba14aa9)) | ||||
| * **shorcuts:** add shortcut to toggle the theme ([#602](https://github.com/chartdb/chartdb/issues/602)) ([a643852](https://github.com/chartdb/chartdb/commit/a6438528375ab54d3ec7d80ac6b6ddd65ea8cf1e)) | ||||
|  | ||||
| ## [1.8.1](https://github.com/chartdb/chartdb/compare/v1.8.0...v1.8.1) (2025-03-02) | ||||
|  | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| * **add-docs:** add link to ChartDB documentation ([#597](https://github.com/chartdb/chartdb/issues/597)) ([b55d631](https://github.com/chartdb/chartdb/commit/b55d631146ff3a1f7d63c800d44b5d3d3a223c76)) | ||||
| * components config ([#591](https://github.com/chartdb/chartdb/issues/591)) ([cbc4e85](https://github.com/chartdb/chartdb/commit/cbc4e85a14e24a43f9ff470518f8fe2845046bdb)) | ||||
| * **docker config:** Environment Variable Handling and Configuration Logic ([#605](https://github.com/chartdb/chartdb/issues/605)) ([d6919f3](https://github.com/chartdb/chartdb/commit/d6919f30336cc846fe6e6505b5a5278aa14dcce6)) | ||||
| * **empty-state:** show diff buttons on import-dbml when triggered by empty ([#574](https://github.com/chartdb/chartdb/issues/574)) ([4834247](https://github.com/chartdb/chartdb/commit/48342471ac231922f2ca4455b74a9879127a54f1)) | ||||
| * **i18n:** add [FR] translation ([#579](https://github.com/chartdb/chartdb/issues/579)) ([ab89bad](https://github.com/chartdb/chartdb/commit/ab89bad6d544ba4c339a3360eeec7d29e5579511)) | ||||
| * **img-export:** add ChartDB watermark to exported image ([#588](https://github.com/chartdb/chartdb/issues/588)) ([b935b7f](https://github.com/chartdb/chartdb/commit/b935b7f25111d5f72b7f8d7c552a4ea5974f791e)) | ||||
| * **import-mssql:** fix import/export scripts to handle data correctly ([#598](https://github.com/chartdb/chartdb/issues/598)) ([e06eb2a](https://github.com/chartdb/chartdb/commit/e06eb2a48e6bd3bcf352f4bcf128214c7da4c1b1)) | ||||
| * **menu-backup:** update export to be backup ([#590](https://github.com/chartdb/chartdb/issues/590)) ([26a0a5b](https://github.com/chartdb/chartdb/commit/26a0a5b550ef5e47e89b00d0232dc98936f63f23)) | ||||
| * open create new diagram when there is no diagram ([#594](https://github.com/chartdb/chartdb/issues/594)) ([ef11892](https://github.com/chartdb/chartdb/commit/ef118929ad5d5cbfae0290061bd8ea30bd262496)) | ||||
| * **open diagram:** in case there is no diagram, opens the dialog ([#593](https://github.com/chartdb/chartdb/issues/593)) ([68f4819](https://github.com/chartdb/chartdb/commit/68f48190c93f155398cca15dd7af2a025de2d45f)) | ||||
| * **side-panel:** simplify how to add field and index ([#573](https://github.com/chartdb/chartdb/issues/573)) ([a1c0cf1](https://github.com/chartdb/chartdb/commit/a1c0cf102add4fb235e913e75078139b3961341b)) | ||||
| * **sql_server_export:** use sql server export ([#600](https://github.com/chartdb/chartdb/issues/600)) ([56382a9](https://github.com/chartdb/chartdb/commit/56382a9fdc5e3044f8811873dd8a79f590771896)) | ||||
| * **sqlite-import:** import nuallable columns correctly + add json type ([#571](https://github.com/chartdb/chartdb/issues/571)) ([deb2184](https://github.com/chartdb/chartdb/commit/deb218423f77f0c0945a93005696456f62b00ce3)) | ||||
|  | ||||
| ## [1.8.0](https://github.com/chartdb/chartdb/compare/v1.7.0...v1.8.0) (2025-02-13) | ||||
|  | ||||
|  | ||||
| ### Features | ||||
|  | ||||
| * **dbml-import:** add error highlighting for dbml imports ([#556](https://github.com/chartdb/chartdb/issues/556)) ([190e4f4](https://github.com/chartdb/chartdb/commit/190e4f4ffa834fa621f264dc608ca3f3b393a331)) | ||||
| * **docker image:** add support for custom inference servers ([#543](https://github.com/chartdb/chartdb/issues/543)) ([1878083](https://github.com/chartdb/chartdb/commit/1878083056ea4db7a05cdeeb38a4f7b9f5f95bd1)) | ||||
|  | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| * **canvas:** add right-click option to create relationships ([#568](https://github.com/chartdb/chartdb/issues/568)) ([e993f15](https://github.com/chartdb/chartdb/commit/e993f1549c4c86bb9e7e36062db803ba6613b3b3)) | ||||
| * **canvas:** locate table from canvas ([#560](https://github.com/chartdb/chartdb/issues/560)) ([dc404c9](https://github.com/chartdb/chartdb/commit/dc404c9d7ee272c93aac69646bac859829a5234e)) | ||||
| * **docker:** add option to hide popups ([#580](https://github.com/chartdb/chartdb/issues/580)) ([a96c2e1](https://github.com/chartdb/chartdb/commit/a96c2e107838d2dc13b586923fd9dbe06598cdd8)) | ||||
| * **export-sql:** show create script for only filtered schemas ([#570](https://github.com/chartdb/chartdb/issues/570)) ([85fd14f](https://github.com/chartdb/chartdb/commit/85fd14fa02bb2879c36bba53369dbf2e7fa578d4)) | ||||
| * **i18n:** fix Ukrainian ([#554](https://github.com/chartdb/chartdb/issues/554)) ([7b62719](https://github.com/chartdb/chartdb/commit/7b6271962a99bfe5ffbd0176e714c76368ef5c41)) | ||||
| * **import dbml:** add import for indexes ([#566](https://github.com/chartdb/chartdb/issues/566)) ([0db67ea](https://github.com/chartdb/chartdb/commit/0db67ea42a5f9585ca1d246db7a7ff0239bec0ba)) | ||||
| * **import-query:** improve the cleanup for messy json input ([#562](https://github.com/chartdb/chartdb/issues/562)) ([93d59f8](https://github.com/chartdb/chartdb/commit/93d59f8887765098d040a3184aaee32112f67267)) | ||||
| * **index unique:** extract unique toggle for faster editing ([#559](https://github.com/chartdb/chartdb/issues/559)) ([dd4324d](https://github.com/chartdb/chartdb/commit/dd4324d64f7638ada5c022a2ab38bd8e6986af25)) | ||||
| * **mssql-import:** improve script readability by adding edition comment ([#572](https://github.com/chartdb/chartdb/issues/572)) ([be65328](https://github.com/chartdb/chartdb/commit/be65328f24b0361638b9e2edb39eaa9906e77f67)) | ||||
| * **realtionships section:** add the schema to source/target tables ([#561](https://github.com/chartdb/chartdb/issues/561)) ([b9e621b](https://github.com/chartdb/chartdb/commit/b9e621bd680730a0ffbf1054d735bfa418711cae)) | ||||
| * **sqlserver-import:** open ssms guide when max chars ([#565](https://github.com/chartdb/chartdb/issues/565)) ([9c485b3](https://github.com/chartdb/chartdb/commit/9c485b3b01a131bf551c7e95916b0c416f6aa0b5)) | ||||
| * **table actions:** fix size of table actions ([#578](https://github.com/chartdb/chartdb/issues/578)) ([26d95ee](https://github.com/chartdb/chartdb/commit/26d95eed25d86452d9168a9d93a301ba50d934e3)) | ||||
|  | ||||
| ## [1.7.0](https://github.com/chartdb/chartdb/compare/v1.6.1...v1.7.0) (2025-02-03) | ||||
|  | ||||
|  | ||||
| ### Features | ||||
|  | ||||
| * **dbml-editor:** add dbml editor in side pannel ([#534](https://github.com/chartdb/chartdb/issues/534)) ([88be6c1](https://github.com/chartdb/chartdb/commit/88be6c1fd4a7e1f20937e8204c14d8fc1c2665b4)) | ||||
| * **import-dbml:** add import dbml functionality ([#549](https://github.com/chartdb/chartdb/issues/549)) ([b424518](https://github.com/chartdb/chartdb/commit/b424518212290a870fdb7c420a303f65f5901429)) | ||||
|  | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| * **canvas edit:** add option to edit names in canvas ([#536](https://github.com/chartdb/chartdb/issues/536)) ([0dcc9b9](https://github.com/chartdb/chartdb/commit/0dcc9b9568cfe749d44d2e93cb365ba3d3a1e71c)) | ||||
| * **dbml-editor:** add shortcuts to dbml and filter: [#534](https://github.com/chartdb/chartdb/issues/534) ([#535](https://github.com/chartdb/chartdb/issues/535)) ([3b3be08](https://github.com/chartdb/chartdb/commit/3b3be086b1e8d5acf999f8504580d9e2f956f7da)) | ||||
| * **dbml:** add error handling ([#545](https://github.com/chartdb/chartdb/issues/545)) ([fef6d3f](https://github.com/chartdb/chartdb/commit/fef6d3f4996130a3769d1f25b4b1f2090293a1bf)) | ||||
| * **empty-state:** fix dark-mode for empty-state ([#547](https://github.com/chartdb/chartdb/issues/547)) ([99a8201](https://github.com/chartdb/chartdb/commit/99a820139861546a012d7b562ddbb9b77698151a)) | ||||
| * **examples:** fix employee example dbml ([#544](https://github.com/chartdb/chartdb/issues/544)) ([2118bce](https://github.com/chartdb/chartdb/commit/2118bce0f00d55eb19d22b9fa2d4964ba2533a09)) | ||||
| * **i18n:** translation/Ukrainian ([#529](https://github.com/chartdb/chartdb/issues/529)) ([ff3269e](https://github.com/chartdb/chartdb/commit/ff3269ec0510bbae4bc114e65a1ea86a656e8785)) | ||||
| * **open-diagram:** add arrow keys navigation in open diagram dialog ([#537](https://github.com/chartdb/chartdb/issues/537)) ([14f11c2](https://github.com/chartdb/chartdb/commit/14f11c27a7ad5b990131c8495148cabf12835082)) | ||||
| * **performance:** fix bundle size ([#551](https://github.com/chartdb/chartdb/issues/551)) ([4c93326](https://github.com/chartdb/chartdb/commit/4c93326bb6e3eaa143373c500a0c641e95a53fb9)) | ||||
| * **performance:** reduce bundle size ([#553](https://github.com/chartdb/chartdb/issues/553)) ([004d530](https://github.com/chartdb/chartdb/commit/004d530880a50dea6e9786eb9ae63cf592a4d852)) | ||||
| * **performance:** resolve error on startup ([#552](https://github.com/chartdb/chartdb/issues/552)) ([fd2cc9f](https://github.com/chartdb/chartdb/commit/fd2cc9fcfc8f4a9f0bc79def47d89114159392fb)) | ||||
| * **psql-import:** remove typo for import command (psql) ([#546](https://github.com/chartdb/chartdb/issues/546)) ([eb9b41e](https://github.com/chartdb/chartdb/commit/eb9b41e4f656bec1451c45763f4ea5b547aeec5c)) | ||||
| * **scroll:** fix scroll area ([#550](https://github.com/chartdb/chartdb/issues/550)) ([ef3d7a8](https://github.com/chartdb/chartdb/commit/ef3d7a8b67431e923b75bf8287b86bbc8abe723b)) | ||||
|  | ||||
| ## [1.6.1](https://github.com/chartdb/chartdb/compare/v1.6.0...v1.6.1) (2025-01-26) | ||||
|  | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| * change empty state image ([#531](https://github.com/chartdb/chartdb/issues/531)) ([42d4cba](https://github.com/chartdb/chartdb/commit/42d4cbac8ce352e0e4e155d7003bfb85296b897f)) | ||||
| * **chat-type:** remove typo of char datatype in examples ([#530](https://github.com/chartdb/chartdb/issues/530)) ([58231c9](https://github.com/chartdb/chartdb/commit/58231c91393de30ebff817f0ebc57a5c5579f106)) | ||||
| * **empty_state:** customize empty state ([#533](https://github.com/chartdb/chartdb/issues/533)) ([1643e7b](https://github.com/chartdb/chartdb/commit/1643e7bdeb1bbaf081ab064e871d102c87243c0a)) | ||||
| * **Image Export:** importing css rules error while download image ([#524](https://github.com/chartdb/chartdb/issues/524)) ([e9e2736](https://github.com/chartdb/chartdb/commit/e9e2736cb2203702d53df9afc30b8e989a8c9953)) | ||||
| * **shortcuts:** add zoom all shortcut ([#528](https://github.com/chartdb/chartdb/issues/528)) ([7452ca6](https://github.com/chartdb/chartdb/commit/7452ca6965b0332a93b686c397ddf51013e42506)) | ||||
| * **filter-tables:** show clean filter if no-results ([#532](https://github.com/chartdb/chartdb/issues/532)) ([c36cd33](https://github.com/chartdb/chartdb/commit/c36cd33180badaa9b7f9e27c765f19cb03a50ccd)) | ||||
|  | ||||
| ## [1.6.0](https://github.com/chartdb/chartdb/compare/v1.5.1...v1.6.0) (2025-01-02) | ||||
|  | ||||
|  | ||||
| ### Features | ||||
|  | ||||
| * **view-menu:** add toggle for mini map visibility ([#496](https://github.com/chartdb/chartdb/issues/496)) ([#505](https://github.com/chartdb/chartdb/issues/505)) ([8abf2a7](https://github.com/chartdb/chartdb/commit/8abf2a7bfcc36d39e60ac133b0e5e569de1bbc72)) | ||||
|  | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| * add loadDiagramFromData logic to chartdb provider ([#513](https://github.com/chartdb/chartdb/issues/513)) ([ee659ea](https://github.com/chartdb/chartdb/commit/ee659eaa038a94ee13801801e84152df4d79683d)) | ||||
| * **dependency:** upgrade react query to v7 - clean console warnings ([#504](https://github.com/chartdb/chartdb/issues/504)) ([7c5db08](https://github.com/chartdb/chartdb/commit/7c5db0848e49dfdb7e7120f77003d1e37f8d71b0)) | ||||
| * **i18n:** translation/Arabic ([#509](https://github.com/chartdb/chartdb/issues/509)) ([4b43f72](https://github.com/chartdb/chartdb/commit/4b43f720e90e49d5461e68d188e3865000f52497)) | ||||
|  | ||||
| ## [1.5.1](https://github.com/chartdb/chartdb/compare/v1.5.0...v1.5.1) (2024-12-15) | ||||
|  | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| * **export:** fix SQL server field.nullable type to boolean ([#486](https://github.com/chartdb/chartdb/issues/486)) ([a151f56](https://github.com/chartdb/chartdb/commit/a151f56b5d950e0b5cc54363684ada95889024b3)) | ||||
| * **readme:** Update README.md - add CockroachDB ([#482](https://github.com/chartdb/chartdb/issues/482)) ([2b6b733](https://github.com/chartdb/chartdb/commit/2b6b73326155f18d6d56779c0657a3506e2d2cde)) | ||||
|  | ||||
| ## [1.5.0](https://github.com/chartdb/chartdb/compare/v1.4.0...v1.5.0) (2024-12-11) | ||||
|  | ||||
|  | ||||
| ### Features | ||||
|  | ||||
| * **CockroachDB:** Add CockroachDB support ([#472](https://github.com/chartdb/chartdb/issues/472)) ([5409288](https://github.com/chartdb/chartdb/commit/54092883883b135f6ace51d86754b1df76603d30)) | ||||
| * **i18n:** translate share and dialog sections in Indonesian locale files ([#468](https://github.com/chartdb/chartdb/issues/468)) ([3574cec](https://github.com/chartdb/chartdb/commit/3574cecc7c73dcab404b82115d20e1ad0cd26b37)) | ||||
|  | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| * **core:** fix update diagram id ([#477](https://github.com/chartdb/chartdb/issues/477)) ([348f805](https://github.com/chartdb/chartdb/commit/348f80568e0f686ee478147fdc43a5d43b5c1ebb)) | ||||
| * **dialogs:** fix footer position on dialogs ([#470](https://github.com/chartdb/chartdb/issues/470)) ([2309306](https://github.com/chartdb/chartdb/commit/2309306ef590783b00a2489209092107dd9a3788)) | ||||
| * **sql-server import:** nullable should be boolean instead of string ([#480](https://github.com/chartdb/chartdb/issues/480)) ([635fb53](https://github.com/chartdb/chartdb/commit/635fb53c9f7ebd1e5ef4d9274af041edc08f04c3)) | ||||
|  | ||||
| ## [1.4.0](https://github.com/chartdb/chartdb/compare/v1.3.1...v1.4.0) (2024-12-02) | ||||
|  | ||||
|  | ||||
| ### Features | ||||
|  | ||||
| * **add templates:** add six more templates  ([#452](https://github.com/chartdb/chartdb/issues/452)) ([be1b109](https://github.com/chartdb/chartdb/commit/be1b109f23e62df4cc63fa8914c2754f7809cc08)) | ||||
| * **add templates:** add six more templates (django-axes, laravel-activitylog, octobox, pay-rails, pixelfed, polr) ([#460](https://github.com/chartdb/chartdb/issues/460)) ([03772f6](https://github.com/chartdb/chartdb/commit/03772f6b4f99f9c4350356aa0f2a4666f4f1794d)) | ||||
| * **add templates:** add six more templates (reversion, screeenly, staytus, deployer, devise, talk) ([#457](https://github.com/chartdb/chartdb/issues/457)) ([ddeef3b](https://github.com/chartdb/chartdb/commit/ddeef3b134efa893e1c1e15e2f87c27157200e2d)) | ||||
| * **clickhouse:** add ClickHouse support ([#463](https://github.com/chartdb/chartdb/issues/463)) ([807cd22](https://github.com/chartdb/chartdb/commit/807cd22e0c739f339fa07fe1d2f043c5411ae41f)) | ||||
| * **i18n:** Added bangla translations ([#432](https://github.com/chartdb/chartdb/issues/432)) ([885eb71](https://github.com/chartdb/chartdb/commit/885eb719de577c2652fbed1ed287f38fcc98c148)) | ||||
| * **side-panel:** Add functionality of order tables by drag & drop ([#425](https://github.com/chartdb/chartdb/issues/425)) ([a0e966b](https://github.com/chartdb/chartdb/commit/a0e966b64f8070d4595d47b2fb39e8bbf427b794)) | ||||
|  | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| * **clipboard:** defensive for navigator clipboard ([#462](https://github.com/chartdb/chartdb/issues/462)) ([5fc10a7](https://github.com/chartdb/chartdb/commit/5fc10a7e649fc5877bb297b519b1b6a8b81f1323)) | ||||
| * **import-database:** update database type after importing into an existing generic diagra ([#456](https://github.com/chartdb/chartdb/issues/456)) ([a8fe491](https://github.com/chartdb/chartdb/commit/a8fe491c1b5a30d9f4144cefa9111dd3dfd5df1a)) | ||||
| * **Last Saved:** Translate the "last saved" relative date message ([#400](https://github.com/chartdb/chartdb/issues/400)) ([d45677e](https://github.com/chartdb/chartdb/commit/d45677e92d72efc6cea8f865ce46f0be6ec9961f)) | ||||
| * **mariadb-types:** Add uuid data type ([#459](https://github.com/chartdb/chartdb/issues/459)) ([94656ec](https://github.com/chartdb/chartdb/commit/94656ec7a5435c2da262fb3bc6a6d381d554b0c1)) | ||||
| * window type ([#454](https://github.com/chartdb/chartdb/issues/454)) ([9c7d03c](https://github.com/chartdb/chartdb/commit/9c7d03c285ff6f818eef3199c9b7a530d03a1fec)) | ||||
|  | ||||
| ## [1.3.1](https://github.com/chartdb/chartdb/compare/v1.3.0...v1.3.1) (2024-11-26) | ||||
|  | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| * **docker:** make OPENAI_API_KEY optional in docker run ([#448](https://github.com/chartdb/chartdb/issues/448)) ([4bb4766](https://github.com/chartdb/chartdb/commit/4bb4766e1ac8d69e138668eb8a46de5affe62ceb)) | ||||
|  | ||||
| ## [1.3.0](https://github.com/chartdb/chartdb/compare/v1.2.0...v1.3.0) (2024-11-25) | ||||
|  | ||||
|  | ||||
|   | ||||
							
								
								
									
										16
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						| @@ -1,6 +1,9 @@ | ||||
| FROM node:22-alpine AS builder | ||||
|  | ||||
| ARG VITE_OPENAI_API_KEY | ||||
| ARG VITE_OPENAI_API_ENDPOINT | ||||
| ARG VITE_LLM_MODEL_NAME | ||||
| ARG VITE_HIDE_BUCKLE_DOT_DEV | ||||
|  | ||||
| WORKDIR /usr/src/app | ||||
|  | ||||
| @@ -10,15 +13,20 @@ RUN npm ci | ||||
|  | ||||
| COPY . . | ||||
|  | ||||
| RUN echo "VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY}" > .env && \ | ||||
|     echo "VITE_OPENAI_API_ENDPOINT=${VITE_OPENAI_API_ENDPOINT}" >> .env && \ | ||||
|     echo "VITE_LLM_MODEL_NAME=${VITE_LLM_MODEL_NAME}" >> .env && \ | ||||
|     echo "VITE_HIDE_BUCKLE_DOT_DEV=${VITE_HIDE_BUCKLE_DOT_DEV}" >> .env  | ||||
|  | ||||
| RUN npm run build | ||||
|  | ||||
| # Use a lightweight web server to serve the production build | ||||
| FROM nginx:stable-alpine AS production | ||||
|  | ||||
| COPY --from=builder /usr/src/app/dist /usr/share/nginx/html | ||||
| COPY ./default.conf /etc/nginx/conf.d/default.conf | ||||
| COPY ./default.conf.template /etc/nginx/conf.d/default.conf.template | ||||
| COPY entrypoint.sh /entrypoint.sh | ||||
| RUN chmod +x /entrypoint.sh | ||||
|  | ||||
| # Expose the default port for the Nginx web server | ||||
| EXPOSE 80 | ||||
|  | ||||
| CMD ["nginx", "-g", "daemon off;"] | ||||
| ENTRYPOINT ["/entrypoint.sh"] | ||||
							
								
								
									
										74
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -30,8 +30,8 @@ | ||||
|   <a href="https://discord.gg/QeFwyWSKwC"> | ||||
|     <img src="https://img.shields.io/discord/1277047413705670678?color=5865F2&label=Discord&logo=discord&logoColor=white" alt="Discord community channel" /> | ||||
|   </a> | ||||
|   <a href="https://x.com/chartdb_io"> | ||||
|     <img src="https://img.shields.io/twitter/follow/ChartDB?style=social"/> | ||||
|   <a href="https://x.com/intent/follow?screen_name=jonathanfishner"> | ||||
|     <img src="https://img.shields.io/twitter/follow/jonathanfishner?style=social"/> | ||||
|   </a> | ||||
|  | ||||
| </h4> | ||||
| @@ -49,13 +49,13 @@ Instantly visualize your database schema with a single **"Smart Query."** Custom | ||||
|  | ||||
| **What it does**: | ||||
|  | ||||
| -   **Instant Schema Import** | ||||
|     Run a single query to instantly retrieve your database schema as JSON. This makes it incredibly fast to visualize your database schema, whether for documentation, team discussions, or simply understanding your data better. | ||||
| - **Instant Schema Import** | ||||
|   Run a single query to instantly retrieve your database schema as JSON. This makes it incredibly fast to visualize your database schema, whether for documentation, team discussions, or simply understanding your data better. | ||||
|  | ||||
| -   **AI-Powered Export for Easy Migration** | ||||
|     Our AI-driven export feature allows you to generate the DDL script in the dialect of your choice. Whether you’re migrating from MySQL to PostgreSQL or from SQLite to MariaDB, ChartDB simplifies the process by providing the necessary scripts tailored to your target database. | ||||
| -   **Interactive Editing** | ||||
|     Fine-tune your database schema using our intuitive editor. Easily make adjustments or annotations to better visualize complex structures. | ||||
| - **AI-Powered Export for Easy Migration** | ||||
|   Our AI-driven export feature allows you to generate the DDL script in the dialect of your choice. Whether you're migrating from MySQL to PostgreSQL or from SQLite to MariaDB, ChartDB simplifies the process by providing the necessary scripts tailored to your target database. | ||||
| - **Interactive Editing** | ||||
|   Fine-tune your database schema using our intuitive editor. Easily make adjustments or annotations to better visualize complex structures. | ||||
|  | ||||
| ### Status | ||||
|  | ||||
| @@ -63,12 +63,13 @@ ChartDB is currently in Public Beta. Star and watch this repository to get notif | ||||
|  | ||||
| ### Supported Databases | ||||
|  | ||||
| -   ✅ PostgreSQL (<img src="./src/assets/postgresql_logo_2.png" width="15"/> + <img src="./src/assets/supabase.png" alt="Supabase" width="15"/> + <img src="./src/assets/timescale.png" alt="Timescale" width="15"/> ) | ||||
| -   ✅ MySQL | ||||
| -   ✅ SQL Server | ||||
| -   ✅ MariaDB | ||||
| -   ✅ SQLite | ||||
| -   ✅ ClickHouse | ||||
| - ✅ PostgreSQL (<img src="./src/assets/postgresql_logo_2.png" width="15"/> + <img src="./src/assets/supabase.png" alt="Supabase" width="15"/> + <img src="./src/assets/timescale.png" alt="Timescale" width="15"/> ) | ||||
| - ✅ MySQL | ||||
| - ✅ SQL Server | ||||
| - ✅ MariaDB | ||||
| - ✅ SQLite (<img src="./src/assets/sqlite_logo_2.png" width="15"/> + <img src="./src/assets/cloudflare_d1.png" alt="Cloudflare D1" width="15"/> Cloudflare D1) | ||||
| - ✅ CockroachDB | ||||
| - ✅ ClickHouse | ||||
|  | ||||
| ## Getting Started | ||||
|  | ||||
| @@ -90,20 +91,51 @@ npm run build | ||||
|  | ||||
| Or like this if you want to have AI capabilities: | ||||
|  | ||||
| ``` | ||||
| ```bash | ||||
| npm install | ||||
| VITE_OPENAI_API_KEY=<YOUR_OPEN_AI_KEY> npm run build | ||||
| ``` | ||||
|  | ||||
| ### Running the Docker Container | ||||
| ### Run the Docker Container | ||||
|  | ||||
| ```bash | ||||
| docker build -t chartdb . (If you want AI capabilities, use `docker build --build-arg VITE_OPENAI_API_KEY=<YOUR_OPEN_AI_KEY> -t chartdb .`) | ||||
| docker run -p 8080:80 chartdb | ||||
| docker run -e OPENAI_API_KEY=<YOUR_OPEN_AI_KEY> -p 8080:80 ghcr.io/chartdb/chartdb:latest | ||||
| ``` | ||||
|  | ||||
| #### Build and Run locally | ||||
|  | ||||
| ```bash | ||||
| docker build -t chartdb . | ||||
| docker run -e OPENAI_API_KEY=<YOUR_OPEN_AI_KEY> -p 8080:80 chartdb | ||||
| ``` | ||||
|  | ||||
| #### Using Custom Inference Server | ||||
|  | ||||
| ```bash | ||||
| # Build | ||||
| docker build \ | ||||
|   --build-arg VITE_OPENAI_API_ENDPOINT=<YOUR_ENDPOINT> \ | ||||
|   --build-arg VITE_LLM_MODEL_NAME=<YOUR_MODEL_NAME> \ | ||||
|   -t chartdb . | ||||
|  | ||||
| # Run | ||||
| docker run \ | ||||
|   -e OPENAI_API_ENDPOINT=<YOUR_ENDPOINT> \ | ||||
|   -e LLM_MODEL_NAME=<YOUR_MODEL_NAME> \ | ||||
|   -p 8080:80 chartdb | ||||
| ``` | ||||
|  | ||||
| > **Note:** You must configure either Option 1 (OpenAI API key) OR Option 2 (Custom endpoint and model name) for AI capabilities to work. Do not mix the two options. | ||||
|  | ||||
| Open your browser and navigate to `http://localhost:8080`. | ||||
|  | ||||
| Example configuration for a local vLLM server: | ||||
|  | ||||
| ```bash | ||||
| VITE_OPENAI_API_ENDPOINT=http://localhost:8000/v1 | ||||
| VITE_LLM_MODEL_NAME=Qwen/Qwen2.5-32B-Instruct-AWQ | ||||
| ``` | ||||
|  | ||||
| ## Try it on our website | ||||
|  | ||||
| 1. Go to [ChartDB.io](https://chartdb.io?ref=github_readme_2) | ||||
| @@ -115,9 +147,9 @@ Open your browser and navigate to `http://localhost:8080`. | ||||
|  | ||||
| ## 💚 Community & Support | ||||
|  | ||||
| -   [Discord](https://discord.gg/QeFwyWSKwC) (For live discussion with the community and the ChartDB team) | ||||
| -   [GitHub Issues](https://github.com/chartdb/chartdb/issues) (For any bugs and errors you encounter using ChartDB) | ||||
| -   [Twitter](https://x.com/chartdb_io) (Get news fast) | ||||
| - [Discord](https://discord.gg/QeFwyWSKwC) (For live discussion with the community and the ChartDB team) | ||||
| - [GitHub Issues](https://github.com/chartdb/chartdb/issues) (For any bugs and errors you encounter using ChartDB) | ||||
| - [Twitter](https://x.com/intent/follow?screen_name=jonathanfishner) (Get news fast) | ||||
|  | ||||
| ## Contributing | ||||
|  | ||||
|   | ||||
| @@ -1,17 +1,20 @@ | ||||
| { | ||||
|   "$schema": "https://ui.shadcn.com/schema.json", | ||||
|   "style": "new-york", | ||||
|   "rsc": false, | ||||
|   "tsx": true, | ||||
|   "tailwind": { | ||||
|     "config": "tailwind.config.js", | ||||
|     "css": "src/globals.css", | ||||
|     "baseColor": "slate", | ||||
|     "cssVariables": true, | ||||
|     "prefix": "" | ||||
|   }, | ||||
|   "aliases": { | ||||
|     "components": "src/components", | ||||
|     "utils": "@/lib/utils" | ||||
|   } | ||||
| } | ||||
|     "$schema": "https://ui.shadcn.com/schema.json", | ||||
|     "style": "new-york", | ||||
|     "rsc": false, | ||||
|     "tsx": true, | ||||
|     "tailwind": { | ||||
|         "config": "tailwind.config.js", | ||||
|         "css": "src/globals.css", | ||||
|         "baseColor": "slate", | ||||
|         "cssVariables": true, | ||||
|         "prefix": "" | ||||
|     }, | ||||
|     "aliases": { | ||||
|         "components": "src/components", | ||||
|         "utils": "src/lib/utils", | ||||
|         "ui": "src/components/ui", | ||||
|         "lib": "src/lib", | ||||
|         "hooks": "src/hooks" | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										15
									
								
								default.conf
									
									
									
									
									
								
							
							
						
						| @@ -1,15 +0,0 @@ | ||||
| server { | ||||
|     listen       80; | ||||
|     listen  [::]:80; | ||||
|  | ||||
|     location / { | ||||
|         root   /usr/share/nginx/html; | ||||
|         index  index.html index.htm; | ||||
| 	    try_files  $uri $uri/ /index.html; | ||||
|     } | ||||
|  | ||||
|     error_page   500 502 503 504  /50x.html; | ||||
|     location = /50x.html { | ||||
|         root   /usr/share/nginx/html; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										25
									
								
								default.conf.template
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,25 @@ | ||||
| server { | ||||
|     listen       80; | ||||
|     listen  [::]:80; | ||||
|  | ||||
|     location / { | ||||
|         root   /usr/share/nginx/html; | ||||
|         index  index.html index.htm; | ||||
| 	    try_files  $uri $uri/ /index.html; | ||||
|     } | ||||
|  | ||||
|     location /config.js { | ||||
|         default_type application/javascript; | ||||
|         return 200 "window.env = {  | ||||
|             OPENAI_API_KEY: \"$OPENAI_API_KEY\", | ||||
|             OPENAI_API_ENDPOINT: \"$OPENAI_API_ENDPOINT\", | ||||
|             LLM_MODEL_NAME: \"$LLM_MODEL_NAME\", | ||||
|             HIDE_BUCKLE_DOT_DEV: \"$HIDE_BUCKLE_DOT_DEV\" | ||||
|         };"; | ||||
|     } | ||||
|  | ||||
|     error_page   500 502 503 504  /50x.html; | ||||
|     location = /50x.html { | ||||
|         root   /usr/share/nginx/html; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										7
									
								
								entrypoint.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | ||||
| #!/bin/sh | ||||
|  | ||||
| # Replace placeholders in nginx.conf | ||||
| envsubst '${OPENAI_API_KEY} ${OPENAI_API_ENDPOINT} ${LLM_MODEL_NAME} ${HIDE_BUCKLE_DOT_DEV}' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf | ||||
|  | ||||
| # Start Nginx | ||||
| nginx -g "daemon off;" | ||||
							
								
								
									
										77
									
								
								eslint.config.mjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,77 @@ | ||||
| import { fixupConfigRules, fixupPluginRules } from '@eslint/compat'; | ||||
| import reactRefresh from 'eslint-plugin-react-refresh'; | ||||
| import cssModules from 'eslint-plugin-css-modules'; | ||||
| import tailwindcss from 'eslint-plugin-tailwindcss'; | ||||
| import jsxA11Y from 'eslint-plugin-jsx-a11y'; | ||||
| import globals from 'globals'; | ||||
| import tsParser from '@typescript-eslint/parser'; | ||||
| import path from 'node:path'; | ||||
| import { fileURLToPath } from 'node:url'; | ||||
| import js from '@eslint/js'; | ||||
| import { FlatCompat } from '@eslint/eslintrc'; | ||||
|  | ||||
| const __filename = fileURLToPath(import.meta.url); | ||||
| const __dirname = path.dirname(__filename); | ||||
| const compat = new FlatCompat({ | ||||
|     baseDirectory: __dirname, | ||||
|     recommendedConfig: js.configs.recommended, | ||||
|     allConfig: js.configs.all, | ||||
| }); | ||||
|  | ||||
| export default [ | ||||
|     { | ||||
|         ignores: ['**/dist', '**/.eslintrc.cjs', '**/tailwind.config.js'], | ||||
|         // files: ['**/*.ts', '**/*.tsx'], | ||||
|     }, | ||||
|     ...fixupConfigRules( | ||||
|         compat.extends( | ||||
|             'eslint:recommended', | ||||
|             'plugin:react/recommended', | ||||
|             'plugin:@typescript-eslint/recommended', | ||||
|             'plugin:react-hooks/recommended', | ||||
|             'plugin:css-modules/recommended', | ||||
|             'plugin:tailwindcss/recommended', | ||||
|             'plugin:prettier/recommended' | ||||
|         ) | ||||
|     ), | ||||
|     { | ||||
|         plugins: { | ||||
|             'react-refresh': reactRefresh, | ||||
|             'css-modules': fixupPluginRules(cssModules), | ||||
|             tailwindcss: fixupPluginRules(tailwindcss), | ||||
|             'jsx-a11y': jsxA11Y, | ||||
|         }, | ||||
|  | ||||
|         languageOptions: { | ||||
|             globals: { | ||||
|                 ...globals.browser, | ||||
|             }, | ||||
|  | ||||
|             parser: tsParser, | ||||
|             // parserOptions: { | ||||
|             //     project: './tsconfig.json', | ||||
|             // }, | ||||
|         }, | ||||
|  | ||||
|         settings: { | ||||
|             react: { | ||||
|                 version: 'detect', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         rules: { | ||||
|             '@typescript-eslint/consistent-type-imports': 'error', | ||||
|  | ||||
|             'react-refresh/only-export-components': [ | ||||
|                 'warn', | ||||
|                 { | ||||
|                     allowConstantExport: true, | ||||
|                 }, | ||||
|             ], | ||||
|  | ||||
|             'react/no-unescaped-entities': 'off', | ||||
|             'react/prop-types': 'off', | ||||
|             '@typescript-eslint/no-empty-object-type': 'off', | ||||
|         }, | ||||
|     }, | ||||
| ]; | ||||
| @@ -12,6 +12,7 @@ | ||||
|             href="https://fonts.googleapis.com/css2?family=Raleway:ital,wght@0,100..900;1,100..900&display=swap" | ||||
|             rel="stylesheet" | ||||
|         /> | ||||
|         <script src="/config.js"></script> | ||||
|         <script | ||||
|             src="https://cdn.usefathom.com/script.js" | ||||
|             data-site="PRHIVBNN" | ||||
|   | ||||
							
								
								
									
										21177
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
							
								
								
									
										31
									
								
								package.json
									
									
									
									
									
								
							
							
						
						| @@ -1,18 +1,19 @@ | ||||
| { | ||||
|     "name": "chartdb", | ||||
|     "private": true, | ||||
|     "version": "1.3.0", | ||||
|     "version": "1.11.0", | ||||
|     "type": "module", | ||||
|     "scripts": { | ||||
|         "dev": "vite", | ||||
|         "build": "npm run lint && tsc -b && vite build", | ||||
|         "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", | ||||
|         "lint": "eslint . --report-unused-disable-directives --max-warnings 0", | ||||
|         "lint:fix": "npm run lint -- --fix", | ||||
|         "preview": "vite preview", | ||||
|         "prepare": "husky" | ||||
|     }, | ||||
|     "dependencies": { | ||||
|         "@ai-sdk/openai": "^0.0.51", | ||||
|         "@dbml/core": "^3.9.5", | ||||
|         "@dnd-kit/sortable": "^8.0.0", | ||||
|         "@monaco-editor/react": "^4.6.0", | ||||
|         "@radix-ui/react-accordion": "^1.2.0", | ||||
| @@ -21,27 +22,27 @@ | ||||
|         "@radix-ui/react-checkbox": "^1.1.1", | ||||
|         "@radix-ui/react-collapsible": "^1.1.0", | ||||
|         "@radix-ui/react-context-menu": "^2.2.1", | ||||
|         "@radix-ui/react-dialog": "^1.1.1", | ||||
|         "@radix-ui/react-dialog": "^1.1.6", | ||||
|         "@radix-ui/react-dropdown-menu": "^2.1.1", | ||||
|         "@radix-ui/react-hover-card": "^1.1.1", | ||||
|         "@radix-ui/react-icons": "^1.3.0", | ||||
|         "@radix-ui/react-label": "^2.1.0", | ||||
|         "@radix-ui/react-menubar": "^1.1.1", | ||||
|         "@radix-ui/react-popover": "^1.1.1", | ||||
|         "@radix-ui/react-scroll-area": "^1.1.0", | ||||
|         "@radix-ui/react-scroll-area": "1.2.0", | ||||
|         "@radix-ui/react-select": "^2.1.1", | ||||
|         "@radix-ui/react-separator": "^1.1.0", | ||||
|         "@radix-ui/react-slot": "^1.1.0", | ||||
|         "@radix-ui/react-separator": "^1.1.2", | ||||
|         "@radix-ui/react-slot": "^1.1.2", | ||||
|         "@radix-ui/react-tabs": "^1.1.0", | ||||
|         "@radix-ui/react-toast": "^1.2.1", | ||||
|         "@radix-ui/react-toggle": "^1.1.0", | ||||
|         "@radix-ui/react-toggle-group": "^1.1.0", | ||||
|         "@radix-ui/react-tooltip": "^1.1.2", | ||||
|         "@radix-ui/react-tooltip": "^1.1.8", | ||||
|         "@uidotdev/usehooks": "^2.4.1", | ||||
|         "@xyflow/react": "^12.3.1", | ||||
|         "ahooks": "^3.8.1", | ||||
|         "ai": "^3.3.14", | ||||
|         "class-variance-authority": "^0.7.0", | ||||
|         "class-variance-authority": "^0.7.1", | ||||
|         "clsx": "^2.1.1", | ||||
|         "cmdk": "^1.0.0", | ||||
|         "dexie": "^4.0.8", | ||||
| @@ -60,7 +61,7 @@ | ||||
|         "react-i18next": "^15.0.1", | ||||
|         "react-resizable-panels": "^2.0.22", | ||||
|         "react-responsive": "^10.0.0", | ||||
|         "react-router-dom": "^6.26.0", | ||||
|         "react-router-dom": "^7.1.1", | ||||
|         "react-use": "^17.5.1", | ||||
|         "tailwind-merge": "^2.4.0", | ||||
|         "tailwindcss-animate": "^1.0.7", | ||||
| @@ -69,22 +70,26 @@ | ||||
|         "zod": "^3.23.8" | ||||
|     }, | ||||
|     "devDependencies": { | ||||
|         "@eslint/compat": "^1.2.4", | ||||
|         "@eslint/eslintrc": "^3.2.0", | ||||
|         "@eslint/js": "^9.16.0", | ||||
|         "@types/node": "^22.1.0", | ||||
|         "@types/react": "^18.3.3", | ||||
|         "@types/react-dom": "^18.3.0", | ||||
|         "@typescript-eslint/eslint-plugin": "^7.15.0", | ||||
|         "@typescript-eslint/parser": "^7.15.0", | ||||
|         "@typescript-eslint/eslint-plugin": "^8.18.0", | ||||
|         "@typescript-eslint/parser": "^8.18.0", | ||||
|         "@vitejs/plugin-react": "^4.3.1", | ||||
|         "autoprefixer": "^10.4.20", | ||||
|         "eslint": "^8.57.0", | ||||
|         "eslint": "^9.16.0", | ||||
|         "eslint-config-prettier": "^9.1.0", | ||||
|         "eslint-plugin-css-modules": "^2.12.0", | ||||
|         "eslint-plugin-jsx-a11y": "^6.9.0", | ||||
|         "eslint-plugin-prettier": "^5.2.1", | ||||
|         "eslint-plugin-react": "^7.35.0", | ||||
|         "eslint-plugin-react-hooks": "^4.6.2", | ||||
|         "eslint-plugin-react-hooks": "^5.1.0", | ||||
|         "eslint-plugin-react-refresh": "^0.4.7", | ||||
|         "eslint-plugin-tailwindcss": "^3.17.4", | ||||
|         "globals": "^15.13.0", | ||||
|         "husky": "^9.1.5", | ||||
|         "postcss": "^8.4.40", | ||||
|         "prettier": "^3.3.3", | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								public/buckle-animated.gif
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 404 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/buckle.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 28 KiB | 
							
								
								
									
										0
									
								
								public/config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								src/assets/clickhouse_logo.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 12 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/clickhouse_logo_2.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.3 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/clickhouse_logo_dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 7.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/cloudflare_d1.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 937 B | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/cockroachdb_logo.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 8.0 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/cockroachdb_logo_2.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 270 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/cockroachdb_logo_dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 20 KiB | 
| Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 6.1 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/empty_state_dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.1 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/cachet-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 447 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/cachet-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 486 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/canvas-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 346 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/canvas-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 379 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/deployer-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 424 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/deployer-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 497 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/devise-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 207 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/devise-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 231 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/django-axes-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 250 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/django-axes-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 264 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/doorkeeper-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 288 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/doorkeeper-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 319 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/flipper-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 189 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/flipper-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 207 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/laravel-activitylog-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 198 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/laravel-activitylog-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 217 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/octobox-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 352 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/octobox-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 382 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/orchid-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 303 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/orchid-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 340 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/pay-rails-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 352 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/pay-rails-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 371 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/pixelfed-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 593 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/pixelfed-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 687 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/polr-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 246 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/polr-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 278 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/reversion-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 229 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/reversion-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 266 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/screeenly-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 251 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/screeenly-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 266 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/staytus-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 424 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/staytus-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 471 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/taggit-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 169 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/taggit-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 184 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/talk-db-dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 229 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/templates/talk-db.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 253 KiB | 
| @@ -1,2 +1,3 @@ | ||||
| import './config.ts'; | ||||
| export { Editor } from '@monaco-editor/react'; | ||||
| export { DiffEditor } from '@monaco-editor/react'; | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import React, { lazy, Suspense, useCallback, useEffect } from 'react'; | ||||
| import { Spinner } from '../spinner/spinner'; | ||||
| import { useTheme } from '@/hooks/use-theme'; | ||||
| import { useMonaco } from '@monaco-editor/react'; | ||||
| import { useToast } from '@/components/toast/use-toast'; | ||||
| import { Button } from '../button/button'; | ||||
| import { Copy, CopyCheck } from 'lucide-react'; | ||||
| import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip/tooltip'; | ||||
| @@ -11,33 +12,46 @@ import { DarkTheme } from './themes/dark'; | ||||
| import { LightTheme } from './themes/light'; | ||||
| import './config.ts'; | ||||
|  | ||||
| export interface CodeSnippetProps { | ||||
|     className?: string; | ||||
|     code: string; | ||||
|     language?: 'sql' | 'shell'; | ||||
|     loading?: boolean; | ||||
|     autoScroll?: boolean; | ||||
|     isComplete?: boolean; | ||||
| } | ||||
|  | ||||
| export const Editor = lazy(() => | ||||
|     import('./code-editor').then((module) => ({ | ||||
|         default: module.Editor, | ||||
|     })) | ||||
| ); | ||||
|  | ||||
| export const DiffEditor = lazy(() => | ||||
|     import('./code-editor').then((module) => ({ | ||||
|         default: module.DiffEditor, | ||||
|     })) | ||||
| ); | ||||
|  | ||||
| type EditorType = typeof Editor; | ||||
|  | ||||
| export interface CodeSnippetProps { | ||||
|     className?: string; | ||||
|     code: string; | ||||
|     codeToCopy?: string; | ||||
|     language?: 'sql' | 'shell'; | ||||
|     loading?: boolean; | ||||
|     autoScroll?: boolean; | ||||
|     isComplete?: boolean; | ||||
|     editorProps?: React.ComponentProps<EditorType>; | ||||
| } | ||||
|  | ||||
| export const CodeSnippet: React.FC<CodeSnippetProps> = React.memo( | ||||
|     ({ | ||||
|         className, | ||||
|         code, | ||||
|         codeToCopy, | ||||
|         loading, | ||||
|         language = 'sql', | ||||
|         autoScroll = false, | ||||
|         isComplete = true, | ||||
|         editorProps, | ||||
|     }) => { | ||||
|         const { t } = useTranslation(); | ||||
|         const monaco = useMonaco(); | ||||
|         const { effectiveTheme } = useTheme(); | ||||
|         const { toast } = useToast(); | ||||
|         const [isCopied, setIsCopied] = React.useState(false); | ||||
|         const [tooltipOpen, setTooltipOpen] = React.useState(false); | ||||
|  | ||||
| @@ -66,10 +80,32 @@ export const CodeSnippet: React.FC<CodeSnippetProps> = React.memo( | ||||
|             } | ||||
|         }, [code, monaco, autoScroll]); | ||||
|  | ||||
|         const copyToClipboard = useCallback(() => { | ||||
|             navigator.clipboard.writeText(code); | ||||
|             setIsCopied(true); | ||||
|         }, [code]); | ||||
|         const copyToClipboard = useCallback(async () => { | ||||
|             if (!navigator?.clipboard) { | ||||
|                 toast({ | ||||
|                     title: t('copy_to_clipboard_toast.unsupported.title'), | ||||
|                     variant: 'destructive', | ||||
|                     description: t( | ||||
|                         'copy_to_clipboard_toast.unsupported.description' | ||||
|                     ), | ||||
|                 }); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             try { | ||||
|                 await navigator.clipboard.writeText(codeToCopy ?? code); | ||||
|                 setIsCopied(true); | ||||
|             } catch { | ||||
|                 setIsCopied(false); | ||||
|                 toast({ | ||||
|                     title: t('copy_to_clipboard_toast.failed.title'), | ||||
|                     variant: 'destructive', | ||||
|                     description: t( | ||||
|                         'copy_to_clipboard_toast.failed.description' | ||||
|                     ), | ||||
|                 }); | ||||
|             } | ||||
|         }, [code, codeToCopy, t, toast]); | ||||
|  | ||||
|         return ( | ||||
|             <div | ||||
| @@ -120,27 +156,32 @@ export const CodeSnippet: React.FC<CodeSnippetProps> = React.memo( | ||||
|                             language={language} | ||||
|                             loading={<Spinner />} | ||||
|                             theme={effectiveTheme} | ||||
|                             {...editorProps} | ||||
|                             options={{ | ||||
|                                 minimap: { | ||||
|                                     enabled: false, | ||||
|                                 }, | ||||
|                                 readOnly: true, | ||||
|                                 automaticLayout: true, | ||||
|                                 scrollbar: { | ||||
|                                     vertical: 'hidden', | ||||
|                                     horizontal: 'hidden', | ||||
|                                     alwaysConsumeMouseWheel: false, | ||||
|                                 }, | ||||
|                                 scrollBeyondLastLine: false, | ||||
|                                 renderValidationDecorations: 'off', | ||||
|                                 lineDecorationsWidth: 0, | ||||
|                                 overviewRulerBorder: false, | ||||
|                                 overviewRulerLanes: 0, | ||||
|                                 hideCursorInOverviewRuler: true, | ||||
|                                 contextmenu: false, | ||||
|                                 ...editorProps?.options, | ||||
|                                 guides: { | ||||
|                                     indentation: false, | ||||
|                                     ...editorProps?.options?.guides, | ||||
|                                 }, | ||||
|                                 scrollbar: { | ||||
|                                     vertical: 'hidden', | ||||
|                                     horizontal: 'hidden', | ||||
|                                     alwaysConsumeMouseWheel: false, | ||||
|                                     ...editorProps?.options?.scrollbar, | ||||
|                                 }, | ||||
|                                 minimap: { | ||||
|                                     enabled: false, | ||||
|                                     ...editorProps?.options?.minimap, | ||||
|                                 }, | ||||
|                                 contextmenu: false, | ||||
|                             }} | ||||
|                         /> | ||||
|                         {!isComplete ? ( | ||||
|   | ||||
							
								
								
									
										54
									
								
								src/components/code-snippet/languages/dbml-language.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,54 @@ | ||||
| import type { Monaco } from '@monaco-editor/react'; | ||||
| import { dataTypes } from '@/lib/data/data-types/data-types'; | ||||
|  | ||||
| export const setupDBMLLanguage = (monaco: Monaco) => { | ||||
|     monaco.languages.register({ id: 'dbml' }); | ||||
|  | ||||
|     // Define themes for DBML | ||||
|     monaco.editor.defineTheme('dbml-dark', { | ||||
|         base: 'vs-dark', | ||||
|         inherit: true, | ||||
|         rules: [ | ||||
|             { token: 'keyword', foreground: '569CD6' }, // Table, Ref keywords | ||||
|             { token: 'string', foreground: 'CE9178' }, // Strings | ||||
|             { token: 'annotation', foreground: '9CDCFE' }, // [annotations] | ||||
|             { token: 'delimiter', foreground: 'D4D4D4' }, // Braces {} | ||||
|             { token: 'operator', foreground: 'D4D4D4' }, // Operators | ||||
|             { token: 'datatype', foreground: '4EC9B0' }, // Data types | ||||
|         ], | ||||
|         colors: {}, | ||||
|     }); | ||||
|  | ||||
|     monaco.editor.defineTheme('dbml-light', { | ||||
|         base: 'vs', | ||||
|         inherit: true, | ||||
|         rules: [ | ||||
|             { token: 'keyword', foreground: '0000FF' }, // Table, Ref keywords | ||||
|             { token: 'string', foreground: 'A31515' }, // Strings | ||||
|             { token: 'annotation', foreground: '001080' }, // [annotations] | ||||
|             { token: 'delimiter', foreground: '000000' }, // Braces {} | ||||
|             { token: 'operator', foreground: '000000' }, // Operators | ||||
|             { token: 'type', foreground: '267F99' }, // Data types | ||||
|         ], | ||||
|         colors: {}, | ||||
|     }); | ||||
|  | ||||
|     const dataTypesNames = dataTypes.map((dt) => dt.name); | ||||
|     const datatypePattern = dataTypesNames.join('|'); | ||||
|  | ||||
|     monaco.languages.setMonarchTokensProvider('dbml', { | ||||
|         keywords: ['Table', 'Ref', 'Indexes'], | ||||
|         datatypes: dataTypesNames, | ||||
|         tokenizer: { | ||||
|             root: [ | ||||
|                 [/\b(Table|Ref|Indexes)\b/, 'keyword'], | ||||
|                 [/\[.*?\]/, 'annotation'], | ||||
|                 [/".*?"/, 'string'], | ||||
|                 [/'.*?'/, 'string'], | ||||
|                 [/[{}]/, 'delimiter'], | ||||
|                 [/[<>]/, 'operator'], | ||||
|                 [new RegExp(`\\b(${datatypePattern})\\b`, 'i'), 'type'], // Added 'i' flag for case-insensitive matching | ||||
|             ], | ||||
|         }, | ||||
|     }); | ||||
| }; | ||||
| @@ -1,6 +1,6 @@ | ||||
| import React from 'react'; | ||||
| import type { Diagram } from '@/lib/domain/diagram'; | ||||
| import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip/tooltip'; | ||||
| import type { DatabaseEdition } from '@/lib/domain/database-edition'; | ||||
| import { | ||||
|     databaseEditionToImageMap, | ||||
|     databaseEditionToLabelMap, | ||||
| @@ -9,39 +9,44 @@ import { | ||||
|     databaseSecondaryLogoMap, | ||||
|     databaseTypeToLabelMap, | ||||
| } from '@/lib/databases'; | ||||
| import type { DatabaseType } from '@/lib/domain/database-type'; | ||||
| import { cn } from '@/lib/utils'; | ||||
|  | ||||
| export interface DiagramIconProps { | ||||
|     diagram: Diagram; | ||||
| export interface DiagramIconProps | ||||
|     extends React.ComponentPropsWithoutRef<'div'> { | ||||
|     databaseType: DatabaseType; | ||||
|     databaseEdition?: DatabaseEdition; | ||||
|     imgClassName?: string; | ||||
| } | ||||
|  | ||||
| export const DiagramIcon = React.forwardRef< | ||||
|     React.ElementRef<typeof TooltipTrigger>, | ||||
|     DiagramIconProps | ||||
| >(({ diagram }, ref) => | ||||
|     diagram.databaseEdition ? ( | ||||
| >(({ databaseType, databaseEdition, className, imgClassName }, ref) => | ||||
|     databaseEdition ? ( | ||||
|         <Tooltip> | ||||
|             <TooltipTrigger className="mr-1" ref={ref}> | ||||
|             <TooltipTrigger className={cn('mr-1', className)} ref={ref} asChild> | ||||
|                 <img | ||||
|                     src={databaseEditionToImageMap[diagram.databaseEdition]} | ||||
|                     className="h-5 max-w-fit rounded-full" | ||||
|                     src={databaseEditionToImageMap[databaseEdition]} | ||||
|                     className={cn('h-5 max-w-fit rounded-full', imgClassName)} | ||||
|                     alt="database" | ||||
|                 /> | ||||
|             </TooltipTrigger> | ||||
|             <TooltipContent> | ||||
|                 {databaseEditionToLabelMap[diagram.databaseEdition]} | ||||
|                 {databaseEditionToLabelMap[databaseEdition]} | ||||
|             </TooltipContent> | ||||
|         </Tooltip> | ||||
|     ) : ( | ||||
|         <Tooltip> | ||||
|             <TooltipTrigger className="mr-2" ref={ref}> | ||||
|             <TooltipTrigger className={cn('mr-2', className)} ref={ref} asChild> | ||||
|                 <img | ||||
|                     src={databaseSecondaryLogoMap[diagram.databaseType]} | ||||
|                     className="h-5 max-w-fit" | ||||
|                     src={databaseSecondaryLogoMap[databaseType]} | ||||
|                     className={cn('h-5 max-w-fit', imgClassName)} | ||||
|                     alt="database" | ||||
|                 /> | ||||
|             </TooltipTrigger> | ||||
|             <TooltipContent> | ||||
|                 {databaseTypeToLabelMap[diagram.databaseType]} | ||||
|                 {databaseTypeToLabelMap[databaseType]} | ||||
|             </TooltipContent> | ||||
|         </Tooltip> | ||||
|     ) | ||||
|   | ||||
| @@ -117,7 +117,10 @@ const DialogInternalContent = React.forwardRef< | ||||
| >(({ className, ...props }, ref) => ( | ||||
|     <ScrollArea | ||||
|         ref={ref} | ||||
|         className={cn('flex max-h-screen flex-col overflow-y-auto', className)} | ||||
|         className={cn( | ||||
|             'flex flex-1 max-h-screen flex-col overflow-y-auto', | ||||
|             className | ||||
|         )} | ||||
|         {...props} | ||||
|     /> | ||||
| )); | ||||
|   | ||||
| @@ -1,30 +1,66 @@ | ||||
| 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'; | ||||
|  | ||||
| export interface EmptyStateProps { | ||||
|     title: string; | ||||
|     description: string; | ||||
|     imageClassName?: string; | ||||
|     titleClassName?: string; | ||||
|     descriptionClassName?: string; | ||||
| } | ||||
|  | ||||
| export const EmptyState = forwardRef< | ||||
|     HTMLDivElement, | ||||
|     React.HTMLAttributes<HTMLDivElement> & EmptyStateProps | ||||
| >(({ title, description, className }, ref) => ( | ||||
|     <div | ||||
|         ref={ref} | ||||
|         className={cn( | ||||
|             'flex flex-1 flex-col items-center justify-center space-y-1', | ||||
|             className | ||||
|         )} | ||||
|     > | ||||
|         <img src={EmptyStateImage} alt="Empty state" className="w-32" /> | ||||
|         <Label className="text-base">{title}</Label> | ||||
|         <Label className="text-sm font-normal text-muted-foreground"> | ||||
|             {description} | ||||
|         </Label> | ||||
|     </div> | ||||
| )); | ||||
| >( | ||||
|     ( | ||||
|         { | ||||
|             title, | ||||
|             description, | ||||
|             className, | ||||
|             titleClassName, | ||||
|             descriptionClassName, | ||||
|             imageClassName, | ||||
|         }, | ||||
|         ref | ||||
|     ) => { | ||||
|         const { effectiveTheme } = useTheme(); | ||||
|  | ||||
|         return ( | ||||
|             <div | ||||
|                 ref={ref} | ||||
|                 className={cn( | ||||
|                     'flex flex-1 flex-col items-center justify-center space-y-1', | ||||
|                     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 font-normal text-muted-foreground', | ||||
|                         descriptionClassName | ||||
|                     )} | ||||
|                 > | ||||
|                     {description} | ||||
|                 </Label> | ||||
|             </div> | ||||
|         ); | ||||
|     } | ||||
| ); | ||||
|  | ||||
| EmptyState.displayName = 'EmptyState'; | ||||
|   | ||||
| @@ -24,12 +24,19 @@ export interface SelectBoxOption { | ||||
|     value: string; | ||||
|     label: string; | ||||
|     description?: string; | ||||
|     regex?: string; | ||||
|     extractRegex?: RegExp; | ||||
| } | ||||
|  | ||||
| export interface SelectBoxProps { | ||||
|     options: SelectBoxOption[]; | ||||
|     value?: string[] | string; | ||||
|     onChange?: (values: string[] | string) => void; | ||||
|     valueSuffix?: string; | ||||
|     optionSuffix?: (option: SelectBoxOption) => string; | ||||
|     onChange?: ( | ||||
|         values: string[] | string, | ||||
|         regexMatches?: string[] | string | ||||
|     ) => void; | ||||
|     placeholder?: string; | ||||
|     inputPlaceholder?: string; | ||||
|     emptyPlaceholder?: string; | ||||
| @@ -55,10 +62,12 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>( | ||||
|             className, | ||||
|             options, | ||||
|             value, | ||||
|             valueSuffix, | ||||
|             onChange, | ||||
|             multiple, | ||||
|             oneLine, | ||||
|             selectAll, | ||||
|             optionSuffix, | ||||
|             deselectAll, | ||||
|             clearText, | ||||
|             showClear, | ||||
| @@ -86,7 +95,7 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>( | ||||
|         ); | ||||
|  | ||||
|         const handleSelect = React.useCallback( | ||||
|             (selectedValue: string) => { | ||||
|             (selectedValue: string, regexMatches?: string[]) => { | ||||
|                 if (multiple) { | ||||
|                     const newValue = | ||||
|                         value?.includes(selectedValue) && Array.isArray(value) | ||||
| @@ -94,7 +103,7 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>( | ||||
|                             : [...(value ?? []), selectedValue]; | ||||
|                     onChange?.(newValue); | ||||
|                 } else { | ||||
|                     onChange?.(selectedValue); | ||||
|                     onChange?.(selectedValue, regexMatches); | ||||
|                     setIsOpen(false); | ||||
|                 } | ||||
|             }, | ||||
| @@ -199,6 +208,7 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>( | ||||
|                                                 (opt) => opt.value === value | ||||
|                                             )?.label | ||||
|                                         } | ||||
|                                         {valueSuffix ? valueSuffix : ''} | ||||
|                                     </div> | ||||
|                                 ) | ||||
|                             ) : ( | ||||
| @@ -239,11 +249,22 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>( | ||||
|                     align="center" | ||||
|                 > | ||||
|                     <Command | ||||
|                         filter={(value, search) => | ||||
|                             value.toLowerCase().includes(search.toLowerCase()) | ||||
|                         filter={(value, search, keywords) => { | ||||
|                             if ( | ||||
|                                 keywords?.length && | ||||
|                                 keywords.some((keyword) => | ||||
|                                     new RegExp(keyword).test(search) | ||||
|                                 ) | ||||
|                             ) { | ||||
|                                 return 1; | ||||
|                             } | ||||
|  | ||||
|                             return value | ||||
|                                 .toLowerCase() | ||||
|                                 .includes(search.toLowerCase()) | ||||
|                                 ? 1 | ||||
|                                 : 0 | ||||
|                         } | ||||
|                                 : 0; | ||||
|                         }} | ||||
|                     > | ||||
|                         <div className="relative"> | ||||
|                             <CommandInput | ||||
| @@ -302,14 +323,36 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>( | ||||
|                                             const isSelected = | ||||
|                                                 Array.isArray(value) && | ||||
|                                                 value.includes(option.value); | ||||
|  | ||||
|                                             const isRegexMatch = | ||||
|                                                 option.regex && | ||||
|                                                 new RegExp(option.regex)?.test( | ||||
|                                                     searchTerm | ||||
|                                                 ); | ||||
|  | ||||
|                                             const matches = option.extractRegex | ||||
|                                                 ? searchTerm.match( | ||||
|                                                       option.extractRegex | ||||
|                                                   ) | ||||
|                                                 : undefined; | ||||
|  | ||||
|                                             return ( | ||||
|                                                 <CommandItem | ||||
|                                                     className="flex items-center" | ||||
|                                                     key={option.value} | ||||
|                                                     keywords={ | ||||
|                                                         option.regex | ||||
|                                                             ? [option.regex] | ||||
|                                                             : undefined | ||||
|                                                     } | ||||
|                                                     // value={option.value} | ||||
|                                                     onSelect={() => | ||||
|                                                         handleSelect( | ||||
|                                                             option.value | ||||
|                                                             option.value, | ||||
|                                                             matches?.map( | ||||
|                                                                 (match) => | ||||
|                                                                     match.toString() | ||||
|                                                             ) | ||||
|                                                         ) | ||||
|                                                     } | ||||
|                                                 > | ||||
| @@ -327,7 +370,15 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>( | ||||
|                                                     )} | ||||
|                                                     <div className="flex items-center truncate"> | ||||
|                                                         <span> | ||||
|                                                             {option.label} | ||||
|                                                             {isRegexMatch | ||||
|                                                                 ? searchTerm | ||||
|                                                                 : option.label} | ||||
|                                                             {!isRegexMatch && | ||||
|                                                             optionSuffix | ||||
|                                                                 ? optionSuffix( | ||||
|                                                                       option | ||||
|                                                                   ) | ||||
|                                                                 : ''} | ||||
|                                                         </span> | ||||
|                                                         {option.description && ( | ||||
|                                                             <span className="ml-1 text-xs text-muted-foreground"> | ||||
| @@ -337,19 +388,20 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>( | ||||
|                                                             </span> | ||||
|                                                         )} | ||||
|                                                     </div> | ||||
|                                                     {!multiple && | ||||
|                                                     {((!multiple && | ||||
|                                                         option.value === | ||||
|                                                             value && ( | ||||
|                                                             <CheckIcon | ||||
|                                                                 className={cn( | ||||
|                                                                     'ml-auto', | ||||
|                                                                     option.value === | ||||
|                                                                         value | ||||
|                                                                         ? 'opacity-100' | ||||
|                                                                         : 'opacity-0' | ||||
|                                                                 )} | ||||
|                                                             /> | ||||
|                                                         )} | ||||
|                                                             value) || | ||||
|                                                         isRegexMatch) && ( | ||||
|                                                         <CheckIcon | ||||
|                                                             className={cn( | ||||
|                                                                 'ml-auto', | ||||
|                                                                 option.value === | ||||
|                                                                     value | ||||
|                                                                     ? 'opacity-100' | ||||
|                                                                     : 'opacity-0' | ||||
|                                                             )} | ||||
|                                                         /> | ||||
|                                                     )} | ||||
|                                                 </CommandItem> | ||||
|                                             ); | ||||
|                                         })} | ||||
|   | ||||
							
								
								
									
										135
									
								
								src/components/sheet/sheet.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,135 @@ | ||||
| import * as React from 'react'; | ||||
| import * as SheetPrimitive from '@radix-ui/react-dialog'; | ||||
| import { cva, type VariantProps } from 'class-variance-authority'; | ||||
| import { cn } from '@/lib/utils'; | ||||
| import { Cross2Icon } from '@radix-ui/react-icons'; | ||||
|  | ||||
| const Sheet = SheetPrimitive.Root; | ||||
|  | ||||
| const SheetTrigger = SheetPrimitive.Trigger; | ||||
|  | ||||
| const SheetClose = SheetPrimitive.Close; | ||||
|  | ||||
| const SheetPortal = SheetPrimitive.Portal; | ||||
|  | ||||
| const SheetOverlay = React.forwardRef< | ||||
|     React.ElementRef<typeof SheetPrimitive.Overlay>, | ||||
|     React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|     <SheetPrimitive.Overlay | ||||
|         className={cn( | ||||
|             'fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', | ||||
|             className | ||||
|         )} | ||||
|         {...props} | ||||
|         ref={ref} | ||||
|     /> | ||||
| )); | ||||
| SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; | ||||
|  | ||||
| const sheetVariants = cva( | ||||
|     'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out', | ||||
|     { | ||||
|         variants: { | ||||
|             side: { | ||||
|                 top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', | ||||
|                 bottom: 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', | ||||
|                 left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', | ||||
|                 right: 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', | ||||
|             }, | ||||
|         }, | ||||
|         defaultVariants: { | ||||
|             side: 'right', | ||||
|         }, | ||||
|     } | ||||
| ); | ||||
|  | ||||
| interface SheetContentProps | ||||
|     extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, | ||||
|         VariantProps<typeof sheetVariants> {} | ||||
|  | ||||
| const SheetContent = React.forwardRef< | ||||
|     React.ElementRef<typeof SheetPrimitive.Content>, | ||||
|     SheetContentProps | ||||
| >(({ side = 'right', className, children, ...props }, ref) => ( | ||||
|     <SheetPortal> | ||||
|         <SheetOverlay /> | ||||
|         <SheetPrimitive.Content | ||||
|             ref={ref} | ||||
|             className={cn(sheetVariants({ side }), className)} | ||||
|             {...props} | ||||
|         > | ||||
|             <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> | ||||
|                 <Cross2Icon className="size-4" /> | ||||
|                 <span className="sr-only">Close</span> | ||||
|             </SheetPrimitive.Close> | ||||
|             {children} | ||||
|         </SheetPrimitive.Content> | ||||
|     </SheetPortal> | ||||
| )); | ||||
| SheetContent.displayName = SheetPrimitive.Content.displayName; | ||||
|  | ||||
| const SheetHeader = ({ | ||||
|     className, | ||||
|     ...props | ||||
| }: React.HTMLAttributes<HTMLDivElement>) => ( | ||||
|     <div | ||||
|         className={cn( | ||||
|             'flex flex-col space-y-2 text-center sm:text-left', | ||||
|             className | ||||
|         )} | ||||
|         {...props} | ||||
|     /> | ||||
| ); | ||||
| SheetHeader.displayName = 'SheetHeader'; | ||||
|  | ||||
| const SheetFooter = ({ | ||||
|     className, | ||||
|     ...props | ||||
| }: React.HTMLAttributes<HTMLDivElement>) => ( | ||||
|     <div | ||||
|         className={cn( | ||||
|             'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', | ||||
|             className | ||||
|         )} | ||||
|         {...props} | ||||
|     /> | ||||
| ); | ||||
| SheetFooter.displayName = 'SheetFooter'; | ||||
|  | ||||
| const SheetTitle = React.forwardRef< | ||||
|     React.ElementRef<typeof SheetPrimitive.Title>, | ||||
|     React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|     <SheetPrimitive.Title | ||||
|         ref={ref} | ||||
|         className={cn('text-lg font-semibold text-foreground', className)} | ||||
|         {...props} | ||||
|     /> | ||||
| )); | ||||
| SheetTitle.displayName = SheetPrimitive.Title.displayName; | ||||
|  | ||||
| const SheetDescription = React.forwardRef< | ||||
|     React.ElementRef<typeof SheetPrimitive.Description>, | ||||
|     React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|     <SheetPrimitive.Description | ||||
|         ref={ref} | ||||
|         className={cn('text-sm text-muted-foreground', className)} | ||||
|         {...props} | ||||
|     /> | ||||
| )); | ||||
| SheetDescription.displayName = SheetPrimitive.Description.displayName; | ||||
|  | ||||
| export { | ||||
|     Sheet, | ||||
|     SheetPortal, | ||||
|     SheetOverlay, | ||||
|     SheetTrigger, | ||||
|     SheetClose, | ||||
|     SheetContent, | ||||
|     SheetHeader, | ||||
|     SheetFooter, | ||||
|     SheetTitle, | ||||
|     SheetDescription, | ||||
| }; | ||||
							
								
								
									
										790
									
								
								src/components/sidebar/sidebar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,790 @@ | ||||
| import * as React from 'react'; | ||||
| import { Slot } from '@radix-ui/react-slot'; | ||||
| import type { VariantProps } from 'class-variance-authority'; | ||||
| import { cva } from 'class-variance-authority'; | ||||
| import { useIsMobile } from '@/hooks/use-mobile'; | ||||
| import { cn } from '@/lib/utils'; | ||||
| import { Button } from '@/components/button/button'; | ||||
| import { Input } from '@/components/input/input'; | ||||
| import { Separator } from '@/components/separator/separator'; | ||||
| import { | ||||
|     Sheet, | ||||
|     SheetContent, | ||||
|     SheetDescription, | ||||
|     SheetHeader, | ||||
|     SheetTitle, | ||||
| } from '@/components/sheet/sheet'; | ||||
| import { Skeleton } from '@/components/skeleton/skeleton'; | ||||
| import { | ||||
|     Tooltip, | ||||
|     TooltipContent, | ||||
|     TooltipProvider, | ||||
|     TooltipTrigger, | ||||
| } from '@/components/tooltip/tooltip'; | ||||
| import { ViewVerticalIcon } from '@radix-ui/react-icons'; | ||||
| import { useSidebar } from './use-sidebar'; | ||||
|  | ||||
| const SIDEBAR_COOKIE_NAME = 'sidebar_state'; | ||||
| const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; | ||||
| const SIDEBAR_WIDTH = '16rem'; | ||||
| const SIDEBAR_WIDTH_MOBILE = '18rem'; | ||||
| const SIDEBAR_WIDTH_ICON = '3rem'; | ||||
| const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; | ||||
|  | ||||
| type SidebarContext = { | ||||
|     state: 'expanded' | 'collapsed'; | ||||
|     open: boolean; | ||||
|     setOpen: (open: boolean) => void; | ||||
|     openMobile: boolean; | ||||
|     setOpenMobile: (open: boolean) => void; | ||||
|     isMobile: boolean; | ||||
|     toggleSidebar: () => void; | ||||
| }; | ||||
|  | ||||
| const SidebarContext = React.createContext<SidebarContext | null>(null); | ||||
|  | ||||
| const SidebarProvider = React.forwardRef< | ||||
|     HTMLDivElement, | ||||
|     React.ComponentProps<'div'> & { | ||||
|         defaultOpen?: boolean; | ||||
|         open?: boolean; | ||||
|         onOpenChange?: (open: boolean) => void; | ||||
|     } | ||||
| >( | ||||
|     ( | ||||
|         { | ||||
|             defaultOpen = true, | ||||
|             open: openProp, | ||||
|             onOpenChange: setOpenProp, | ||||
|             className, | ||||
|             style, | ||||
|             children, | ||||
|             ...props | ||||
|         }, | ||||
|         ref | ||||
|     ) => { | ||||
|         const isMobile = useIsMobile(); | ||||
|         const [openMobile, setOpenMobile] = React.useState(false); | ||||
|  | ||||
|         // This is the internal state of the sidebar. | ||||
|         // We use openProp and setOpenProp for control from outside the component. | ||||
|         const [_open, _setOpen] = React.useState(defaultOpen); | ||||
|         const open = openProp ?? _open; | ||||
|         const setOpen = React.useCallback( | ||||
|             (value: boolean | ((value: boolean) => boolean)) => { | ||||
|                 const openState = | ||||
|                     typeof value === 'function' ? value(open) : value; | ||||
|                 if (setOpenProp) { | ||||
|                     setOpenProp(openState); | ||||
|                 } else { | ||||
|                     _setOpen(openState); | ||||
|                 } | ||||
|  | ||||
|                 // This sets the cookie to keep the sidebar state. | ||||
|                 document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; | ||||
|             }, | ||||
|             [setOpenProp, open] | ||||
|         ); | ||||
|  | ||||
|         // Helper to toggle the sidebar. | ||||
|         const toggleSidebar = React.useCallback(() => { | ||||
|             return isMobile | ||||
|                 ? setOpenMobile((open) => !open) | ||||
|                 : setOpen((open) => !open); | ||||
|         }, [isMobile, setOpen, setOpenMobile]); | ||||
|  | ||||
|         // Adds a keyboard shortcut to toggle the sidebar. | ||||
|         React.useEffect(() => { | ||||
|             const handleKeyDown = (event: KeyboardEvent) => { | ||||
|                 if ( | ||||
|                     event.key === SIDEBAR_KEYBOARD_SHORTCUT && | ||||
|                     (event.metaKey || event.ctrlKey) | ||||
|                 ) { | ||||
|                     event.preventDefault(); | ||||
|                     toggleSidebar(); | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             window.addEventListener('keydown', handleKeyDown); | ||||
|             return () => window.removeEventListener('keydown', handleKeyDown); | ||||
|         }, [toggleSidebar]); | ||||
|  | ||||
|         // We add a state so that we can do data-state="expanded" or "collapsed". | ||||
|         // This makes it easier to style the sidebar with Tailwind classes. | ||||
|         const state = open ? 'expanded' : 'collapsed'; | ||||
|  | ||||
|         const contextValue = React.useMemo<SidebarContext>( | ||||
|             () => ({ | ||||
|                 state, | ||||
|                 open, | ||||
|                 setOpen, | ||||
|                 isMobile, | ||||
|                 openMobile, | ||||
|                 setOpenMobile, | ||||
|                 toggleSidebar, | ||||
|             }), | ||||
|             [ | ||||
|                 state, | ||||
|                 open, | ||||
|                 setOpen, | ||||
|                 isMobile, | ||||
|                 openMobile, | ||||
|                 setOpenMobile, | ||||
|                 toggleSidebar, | ||||
|             ] | ||||
|         ); | ||||
|  | ||||
|         return ( | ||||
|             <SidebarContext.Provider value={contextValue}> | ||||
|                 <TooltipProvider delayDuration={0}> | ||||
|                     <div | ||||
|                         style={ | ||||
|                             { | ||||
|                                 '--sidebar-width': SIDEBAR_WIDTH, | ||||
|                                 '--sidebar-width-icon': SIDEBAR_WIDTH_ICON, | ||||
|                                 ...style, | ||||
|                             } as React.CSSProperties | ||||
|                         } | ||||
|                         className={cn( | ||||
|                             'group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar', | ||||
|                             className | ||||
|                         )} | ||||
|                         ref={ref} | ||||
|                         {...props} | ||||
|                     > | ||||
|                         {children} | ||||
|                     </div> | ||||
|                 </TooltipProvider> | ||||
|             </SidebarContext.Provider> | ||||
|         ); | ||||
|     } | ||||
| ); | ||||
| SidebarProvider.displayName = 'SidebarProvider'; | ||||
|  | ||||
| const Sidebar = React.forwardRef< | ||||
|     HTMLDivElement, | ||||
|     React.ComponentProps<'div'> & { | ||||
|         side?: 'left' | 'right'; | ||||
|         variant?: 'sidebar' | 'floating' | 'inset'; | ||||
|         collapsible?: 'offcanvas' | 'icon' | 'none'; | ||||
|     } | ||||
| >( | ||||
|     ( | ||||
|         { | ||||
|             side = 'left', | ||||
|             variant = 'sidebar', | ||||
|             collapsible = 'offcanvas', | ||||
|             className, | ||||
|             children, | ||||
|             ...props | ||||
|         }, | ||||
|         ref | ||||
|     ) => { | ||||
|         const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); | ||||
|  | ||||
|         if (collapsible === 'none') { | ||||
|             return ( | ||||
|                 <div | ||||
|                     className={cn( | ||||
|                         'flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground', | ||||
|                         className | ||||
|                     )} | ||||
|                     ref={ref} | ||||
|                     {...props} | ||||
|                 > | ||||
|                     {children} | ||||
|                 </div> | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         if (isMobile) { | ||||
|             return ( | ||||
|                 <Sheet | ||||
|                     open={openMobile} | ||||
|                     onOpenChange={setOpenMobile} | ||||
|                     {...props} | ||||
|                 > | ||||
|                     <SheetContent | ||||
|                         data-sidebar="sidebar" | ||||
|                         data-mobile="true" | ||||
|                         className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" | ||||
|                         style={ | ||||
|                             { | ||||
|                                 '--sidebar-width': SIDEBAR_WIDTH_MOBILE, | ||||
|                             } as React.CSSProperties | ||||
|                         } | ||||
|                         side={side} | ||||
|                     > | ||||
|                         <SheetHeader className="sr-only"> | ||||
|                             <SheetTitle>Sidebar</SheetTitle> | ||||
|                             <SheetDescription> | ||||
|                                 Displays the mobile sidebar. | ||||
|                             </SheetDescription> | ||||
|                         </SheetHeader> | ||||
|                         <div className="flex size-full flex-col"> | ||||
|                             {children} | ||||
|                         </div> | ||||
|                     </SheetContent> | ||||
|                 </Sheet> | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         return ( | ||||
|             <div | ||||
|                 ref={ref} | ||||
|                 className="group peer hidden text-sidebar-foreground md:block" | ||||
|                 data-state={state} | ||||
|                 data-collapsible={state === 'collapsed' ? collapsible : ''} | ||||
|                 data-variant={variant} | ||||
|                 data-side={side} | ||||
|             > | ||||
|                 {/* This is what handles the sidebar gap on desktop */} | ||||
|                 <div | ||||
|                     className={cn( | ||||
|                         'relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear', | ||||
|                         'group-data-[collapsible=offcanvas]:w-0', | ||||
|                         'group-data-[side=right]:rotate-180', | ||||
|                         variant === 'floating' || variant === 'inset' | ||||
|                             ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]' | ||||
|                             : 'group-data-[collapsible=icon]:w-[--sidebar-width-icon]' | ||||
|                     )} | ||||
|                 /> | ||||
|                 <div | ||||
|                     className={cn( | ||||
|                         'fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex', | ||||
|                         side === 'left' | ||||
|                             ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]' | ||||
|                             : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]', | ||||
|                         // Adjust the padding for floating and inset variants. | ||||
|                         variant === 'floating' || variant === 'inset' | ||||
|                             ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]' | ||||
|                             : 'group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l', | ||||
|                         className | ||||
|                     )} | ||||
|                     {...props} | ||||
|                 > | ||||
|                     <div | ||||
|                         data-sidebar="sidebar" | ||||
|                         className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow" | ||||
|                     > | ||||
|                         {children} | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         ); | ||||
|     } | ||||
| ); | ||||
| Sidebar.displayName = 'Sidebar'; | ||||
|  | ||||
| const SidebarTrigger = React.forwardRef< | ||||
|     React.ElementRef<typeof Button>, | ||||
|     React.ComponentProps<typeof Button> | ||||
| >(({ className, onClick, ...props }, ref) => { | ||||
|     const { toggleSidebar } = useSidebar(); | ||||
|  | ||||
|     return ( | ||||
|         <Button | ||||
|             ref={ref} | ||||
|             data-sidebar="trigger" | ||||
|             variant="ghost" | ||||
|             size="icon" | ||||
|             className={cn('h-7 w-7', className)} | ||||
|             onClick={(event) => { | ||||
|                 onClick?.(event); | ||||
|                 toggleSidebar(); | ||||
|             }} | ||||
|             {...props} | ||||
|         > | ||||
|             <ViewVerticalIcon /> | ||||
|             <span className="sr-only">Toggle Sidebar</span> | ||||
|         </Button> | ||||
|     ); | ||||
| }); | ||||
| SidebarTrigger.displayName = 'SidebarTrigger'; | ||||
|  | ||||
| const SidebarRail = React.forwardRef< | ||||
|     HTMLButtonElement, | ||||
|     React.ComponentProps<'button'> | ||||
| >(({ className, ...props }, ref) => { | ||||
|     const { toggleSidebar } = useSidebar(); | ||||
|  | ||||
|     return ( | ||||
|         <button | ||||
|             ref={ref} | ||||
|             data-sidebar="rail" | ||||
|             aria-label="Toggle Sidebar" | ||||
|             tabIndex={-1} | ||||
|             onClick={toggleSidebar} | ||||
|             title="Toggle Sidebar" | ||||
|             className={cn( | ||||
|                 'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex', | ||||
|                 '[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize', | ||||
|                 '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize', | ||||
|                 'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar', | ||||
|                 '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2', | ||||
|                 '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2', | ||||
|                 className | ||||
|             )} | ||||
|             {...props} | ||||
|         /> | ||||
|     ); | ||||
| }); | ||||
| SidebarRail.displayName = 'SidebarRail'; | ||||
|  | ||||
| const SidebarInset = React.forwardRef< | ||||
|     HTMLDivElement, | ||||
|     React.ComponentProps<'main'> | ||||
| >(({ className, ...props }, ref) => { | ||||
|     return ( | ||||
|         <main | ||||
|             ref={ref} | ||||
|             className={cn( | ||||
|                 'relative flex w-full flex-1 flex-col bg-background', | ||||
|                 'md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow', | ||||
|                 className | ||||
|             )} | ||||
|             {...props} | ||||
|         /> | ||||
|     ); | ||||
| }); | ||||
| SidebarInset.displayName = 'SidebarInset'; | ||||
|  | ||||
| const SidebarInput = React.forwardRef< | ||||
|     React.ElementRef<typeof Input>, | ||||
|     React.ComponentProps<typeof Input> | ||||
| >(({ className, ...props }, ref) => { | ||||
|     return ( | ||||
|         <Input | ||||
|             ref={ref} | ||||
|             data-sidebar="input" | ||||
|             className={cn( | ||||
|                 'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring', | ||||
|                 className | ||||
|             )} | ||||
|             {...props} | ||||
|         /> | ||||
|     ); | ||||
| }); | ||||
| SidebarInput.displayName = 'SidebarInput'; | ||||
|  | ||||
| const SidebarHeader = React.forwardRef< | ||||
|     HTMLDivElement, | ||||
|     React.ComponentProps<'div'> | ||||
| >(({ className, ...props }, ref) => { | ||||
|     return ( | ||||
|         <div | ||||
|             ref={ref} | ||||
|             data-sidebar="header" | ||||
|             className={cn('flex flex-col gap-2 p-2', className)} | ||||
|             {...props} | ||||
|         /> | ||||
|     ); | ||||
| }); | ||||
| SidebarHeader.displayName = 'SidebarHeader'; | ||||
|  | ||||
| const SidebarFooter = React.forwardRef< | ||||
|     HTMLDivElement, | ||||
|     React.ComponentProps<'div'> | ||||
| >(({ className, ...props }, ref) => { | ||||
|     return ( | ||||
|         <div | ||||
|             ref={ref} | ||||
|             data-sidebar="footer" | ||||
|             className={cn('flex flex-col gap-2 p-2', className)} | ||||
|             {...props} | ||||
|         /> | ||||
|     ); | ||||
| }); | ||||
| SidebarFooter.displayName = 'SidebarFooter'; | ||||
|  | ||||
| const SidebarSeparator = React.forwardRef< | ||||
|     React.ElementRef<typeof Separator>, | ||||
|     React.ComponentProps<typeof Separator> | ||||
| >(({ className, ...props }, ref) => { | ||||
|     return ( | ||||
|         <Separator | ||||
|             ref={ref} | ||||
|             data-sidebar="separator" | ||||
|             className={cn('mx-2 w-auto bg-sidebar-border', className)} | ||||
|             {...props} | ||||
|         /> | ||||
|     ); | ||||
| }); | ||||
| SidebarSeparator.displayName = 'SidebarSeparator'; | ||||
|  | ||||
| const SidebarContent = React.forwardRef< | ||||
|     HTMLDivElement, | ||||
|     React.ComponentProps<'div'> | ||||
| >(({ className, ...props }, ref) => { | ||||
|     return ( | ||||
|         <div | ||||
|             ref={ref} | ||||
|             data-sidebar="content" | ||||
|             className={cn( | ||||
|                 'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', | ||||
|                 className | ||||
|             )} | ||||
|             {...props} | ||||
|         /> | ||||
|     ); | ||||
| }); | ||||
| SidebarContent.displayName = 'SidebarContent'; | ||||
|  | ||||
| const SidebarGroup = React.forwardRef< | ||||
|     HTMLDivElement, | ||||
|     React.ComponentProps<'div'> | ||||
| >(({ className, ...props }, ref) => { | ||||
|     return ( | ||||
|         <div | ||||
|             ref={ref} | ||||
|             data-sidebar="group" | ||||
|             className={cn( | ||||
|                 'relative flex w-full min-w-0 flex-col p-2', | ||||
|                 className | ||||
|             )} | ||||
|             {...props} | ||||
|         /> | ||||
|     ); | ||||
| }); | ||||
| SidebarGroup.displayName = 'SidebarGroup'; | ||||
|  | ||||
| const SidebarGroupLabel = React.forwardRef< | ||||
|     HTMLDivElement, | ||||
|     React.ComponentProps<'div'> & { asChild?: boolean } | ||||
| >(({ className, asChild = false, ...props }, ref) => { | ||||
|     const Comp = asChild ? Slot : 'div'; | ||||
|  | ||||
|     return ( | ||||
|         <Comp | ||||
|             ref={ref} | ||||
|             data-sidebar="group-label" | ||||
|             className={cn( | ||||
|                 'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', | ||||
|                 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0', | ||||
|                 className | ||||
|             )} | ||||
|             {...props} | ||||
|         /> | ||||
|     ); | ||||
| }); | ||||
| SidebarGroupLabel.displayName = 'SidebarGroupLabel'; | ||||
|  | ||||
| const SidebarGroupAction = React.forwardRef< | ||||
|     HTMLButtonElement, | ||||
|     React.ComponentProps<'button'> & { asChild?: boolean } | ||||
| >(({ className, asChild = false, ...props }, ref) => { | ||||
|     const Comp = asChild ? Slot : 'button'; | ||||
|  | ||||
|     return ( | ||||
|         <Comp | ||||
|             ref={ref} | ||||
|             data-sidebar="group-action" | ||||
|             className={cn( | ||||
|                 'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', | ||||
|                 // Increases the hit area of the button on mobile. | ||||
|                 'after:absolute after:-inset-2 after:md:hidden', | ||||
|                 'group-data-[collapsible=icon]:hidden', | ||||
|                 className | ||||
|             )} | ||||
|             {...props} | ||||
|         /> | ||||
|     ); | ||||
| }); | ||||
| SidebarGroupAction.displayName = 'SidebarGroupAction'; | ||||
|  | ||||
| const SidebarGroupContent = React.forwardRef< | ||||
|     HTMLDivElement, | ||||
|     React.ComponentProps<'div'> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|     <div | ||||
|         ref={ref} | ||||
|         data-sidebar="group-content" | ||||
|         className={cn('w-full text-sm', className)} | ||||
|         {...props} | ||||
|     /> | ||||
| )); | ||||
| SidebarGroupContent.displayName = 'SidebarGroupContent'; | ||||
|  | ||||
| const SidebarMenu = React.forwardRef< | ||||
|     HTMLUListElement, | ||||
|     React.ComponentProps<'ul'> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|     <ul | ||||
|         ref={ref} | ||||
|         data-sidebar="menu" | ||||
|         className={cn('flex w-full min-w-0 flex-col gap-1', className)} | ||||
|         {...props} | ||||
|     /> | ||||
| )); | ||||
| SidebarMenu.displayName = 'SidebarMenu'; | ||||
|  | ||||
| const SidebarMenuItem = React.forwardRef< | ||||
|     HTMLLIElement, | ||||
|     React.ComponentProps<'li'> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|     <li | ||||
|         ref={ref} | ||||
|         data-sidebar="menu-item" | ||||
|         className={cn('group/menu-item relative', className)} | ||||
|         {...props} | ||||
|     /> | ||||
| )); | ||||
| SidebarMenuItem.displayName = 'SidebarMenuItem'; | ||||
|  | ||||
| const sidebarMenuButtonVariants = cva( | ||||
|     'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', | ||||
|     { | ||||
|         variants: { | ||||
|             variant: { | ||||
|                 default: | ||||
|                     'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', | ||||
|                 outline: | ||||
|                     'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]', | ||||
|             }, | ||||
|             size: { | ||||
|                 default: 'h-8 text-sm', | ||||
|                 sm: 'h-7 text-xs', | ||||
|                 lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0', | ||||
|             }, | ||||
|         }, | ||||
|         defaultVariants: { | ||||
|             variant: 'default', | ||||
|             size: 'default', | ||||
|         }, | ||||
|     } | ||||
| ); | ||||
|  | ||||
| const SidebarMenuButton = React.forwardRef< | ||||
|     HTMLButtonElement, | ||||
|     React.ComponentProps<'button'> & { | ||||
|         asChild?: boolean; | ||||
|         isActive?: boolean; | ||||
|         tooltip?: string | React.ComponentProps<typeof TooltipContent>; | ||||
|     } & VariantProps<typeof sidebarMenuButtonVariants> | ||||
| >( | ||||
|     ( | ||||
|         { | ||||
|             asChild = false, | ||||
|             isActive = false, | ||||
|             variant = 'default', | ||||
|             size = 'default', | ||||
|             tooltip, | ||||
|             className, | ||||
|             ...props | ||||
|         }, | ||||
|         ref | ||||
|     ) => { | ||||
|         const Comp = asChild ? Slot : 'button'; | ||||
|         const { isMobile, state } = useSidebar(); | ||||
|  | ||||
|         const button = ( | ||||
|             <Comp | ||||
|                 ref={ref} | ||||
|                 data-sidebar="menu-button" | ||||
|                 data-size={size} | ||||
|                 data-active={isActive} | ||||
|                 className={cn( | ||||
|                     sidebarMenuButtonVariants({ variant, size }), | ||||
|                     className | ||||
|                 )} | ||||
|                 {...props} | ||||
|             /> | ||||
|         ); | ||||
|  | ||||
|         if (!tooltip) { | ||||
|             return button; | ||||
|         } | ||||
|  | ||||
|         if (typeof tooltip === 'string') { | ||||
|             tooltip = { | ||||
|                 children: tooltip, | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         return ( | ||||
|             <Tooltip> | ||||
|                 <TooltipTrigger asChild>{button}</TooltipTrigger> | ||||
|                 <TooltipContent | ||||
|                     side="right" | ||||
|                     align="center" | ||||
|                     hidden={state !== 'collapsed' || isMobile} | ||||
|                     {...tooltip} | ||||
|                 /> | ||||
|             </Tooltip> | ||||
|         ); | ||||
|     } | ||||
| ); | ||||
| SidebarMenuButton.displayName = 'SidebarMenuButton'; | ||||
|  | ||||
| const SidebarMenuAction = React.forwardRef< | ||||
|     HTMLButtonElement, | ||||
|     React.ComponentProps<'button'> & { | ||||
|         asChild?: boolean; | ||||
|         showOnHover?: boolean; | ||||
|     } | ||||
| >(({ className, asChild = false, showOnHover = false, ...props }, ref) => { | ||||
|     const Comp = asChild ? Slot : 'button'; | ||||
|  | ||||
|     return ( | ||||
|         <Comp | ||||
|             ref={ref} | ||||
|             data-sidebar="menu-action" | ||||
|             className={cn( | ||||
|                 'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0', | ||||
|                 // Increases the hit area of the button on mobile. | ||||
|                 'after:absolute after:-inset-2 after:md:hidden', | ||||
|                 'peer-data-[size=sm]/menu-button:top-1', | ||||
|                 'peer-data-[size=default]/menu-button:top-1.5', | ||||
|                 'peer-data-[size=lg]/menu-button:top-2.5', | ||||
|                 'group-data-[collapsible=icon]:hidden', | ||||
|                 showOnHover && | ||||
|                     'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0', | ||||
|                 className | ||||
|             )} | ||||
|             {...props} | ||||
|         /> | ||||
|     ); | ||||
| }); | ||||
| SidebarMenuAction.displayName = 'SidebarMenuAction'; | ||||
|  | ||||
| const SidebarMenuBadge = React.forwardRef< | ||||
|     HTMLDivElement, | ||||
|     React.ComponentProps<'div'> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|     <div | ||||
|         ref={ref} | ||||
|         data-sidebar="menu-badge" | ||||
|         className={cn( | ||||
|             'pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground', | ||||
|             'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground', | ||||
|             'peer-data-[size=sm]/menu-button:top-1', | ||||
|             'peer-data-[size=default]/menu-button:top-1.5', | ||||
|             'peer-data-[size=lg]/menu-button:top-2.5', | ||||
|             'group-data-[collapsible=icon]:hidden', | ||||
|             className | ||||
|         )} | ||||
|         {...props} | ||||
|     /> | ||||
| )); | ||||
| SidebarMenuBadge.displayName = 'SidebarMenuBadge'; | ||||
|  | ||||
| const SidebarMenuSkeleton = React.forwardRef< | ||||
|     HTMLDivElement, | ||||
|     React.ComponentProps<'div'> & { | ||||
|         showIcon?: boolean; | ||||
|     } | ||||
| >(({ className, showIcon = false, ...props }, ref) => { | ||||
|     // Random width between 50 to 90%. | ||||
|     const width = React.useMemo(() => { | ||||
|         return `${Math.floor(Math.random() * 40) + 50}%`; | ||||
|     }, []); | ||||
|  | ||||
|     return ( | ||||
|         <div | ||||
|             ref={ref} | ||||
|             data-sidebar="menu-skeleton" | ||||
|             className={cn( | ||||
|                 'flex h-8 items-center gap-2 rounded-md px-2', | ||||
|                 className | ||||
|             )} | ||||
|             {...props} | ||||
|         > | ||||
|             {showIcon && ( | ||||
|                 <Skeleton | ||||
|                     className="size-4 rounded-md" | ||||
|                     data-sidebar="menu-skeleton-icon" | ||||
|                 /> | ||||
|             )} | ||||
|             <Skeleton | ||||
|                 className="h-4 max-w-[--skeleton-width] flex-1" | ||||
|                 data-sidebar="menu-skeleton-text" | ||||
|                 style={ | ||||
|                     { | ||||
|                         '--skeleton-width': width, | ||||
|                     } as React.CSSProperties | ||||
|                 } | ||||
|             /> | ||||
|         </div> | ||||
|     ); | ||||
| }); | ||||
| SidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton'; | ||||
|  | ||||
| const SidebarMenuSub = React.forwardRef< | ||||
|     HTMLUListElement, | ||||
|     React.ComponentProps<'ul'> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|     <ul | ||||
|         ref={ref} | ||||
|         data-sidebar="menu-sub" | ||||
|         className={cn( | ||||
|             'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5', | ||||
|             'group-data-[collapsible=icon]:hidden', | ||||
|             className | ||||
|         )} | ||||
|         {...props} | ||||
|     /> | ||||
| )); | ||||
| SidebarMenuSub.displayName = 'SidebarMenuSub'; | ||||
|  | ||||
| const SidebarMenuSubItem = React.forwardRef< | ||||
|     HTMLLIElement, | ||||
|     React.ComponentProps<'li'> | ||||
| >(({ ...props }, ref) => <li ref={ref} {...props} />); | ||||
| SidebarMenuSubItem.displayName = 'SidebarMenuSubItem'; | ||||
|  | ||||
| const SidebarMenuSubButton = React.forwardRef< | ||||
|     HTMLAnchorElement, | ||||
|     React.ComponentProps<'a'> & { | ||||
|         asChild?: boolean; | ||||
|         size?: 'sm' | 'md'; | ||||
|         isActive?: boolean; | ||||
|     } | ||||
| >(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => { | ||||
|     const Comp = asChild ? Slot : 'a'; | ||||
|  | ||||
|     return ( | ||||
|         <Comp | ||||
|             ref={ref} | ||||
|             data-sidebar="menu-sub-button" | ||||
|             data-size={size} | ||||
|             data-active={isActive} | ||||
|             className={cn( | ||||
|                 'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground', | ||||
|                 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground', | ||||
|                 size === 'sm' && 'text-xs', | ||||
|                 size === 'md' && 'text-sm', | ||||
|                 'group-data-[collapsible=icon]:hidden', | ||||
|                 className | ||||
|             )} | ||||
|             {...props} | ||||
|         /> | ||||
|     ); | ||||
| }); | ||||
| SidebarMenuSubButton.displayName = 'SidebarMenuSubButton'; | ||||
|  | ||||
| export { | ||||
|     Sidebar, | ||||
|     SidebarContent, | ||||
|     SidebarFooter, | ||||
|     SidebarGroup, | ||||
|     SidebarGroupAction, | ||||
|     SidebarGroupContent, | ||||
|     SidebarGroupLabel, | ||||
|     SidebarHeader, | ||||
|     SidebarInput, | ||||
|     SidebarInset, | ||||
|     SidebarMenu, | ||||
|     SidebarMenuAction, | ||||
|     SidebarMenuBadge, | ||||
|     SidebarMenuButton, | ||||
|     SidebarMenuItem, | ||||
|     SidebarMenuSkeleton, | ||||
|     SidebarMenuSub, | ||||
|     SidebarMenuSubButton, | ||||
|     SidebarMenuSubItem, | ||||
|     SidebarProvider, | ||||
|     SidebarRail, | ||||
|     SidebarSeparator, | ||||
|     SidebarTrigger, | ||||
|     SidebarContext, | ||||
| }; | ||||
							
								
								
									
										11
									
								
								src/components/sidebar/use-sidebar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,11 @@ | ||||
| import React from 'react'; | ||||
| import { SidebarContext } from './sidebar'; | ||||
|  | ||||
| export const useSidebar = () => { | ||||
|     const context = React.useContext(SidebarContext); | ||||
|     if (!context) { | ||||
|         throw new Error('useSidebar must be used within a SidebarProvider.'); | ||||
|     } | ||||
|  | ||||
|     return context; | ||||
| }; | ||||
							
								
								
									
										16
									
								
								src/components/skeleton/skeleton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,16 @@ | ||||
| import React from 'react'; | ||||
| import { cn } from '@/lib/utils'; | ||||
|  | ||||
| function Skeleton({ | ||||
|     className, | ||||
|     ...props | ||||
| }: React.HTMLAttributes<HTMLDivElement>) { | ||||
|     return ( | ||||
|         <div | ||||
|             className={cn('animate-pulse rounded-md bg-primary/10', className)} | ||||
|             {...props} | ||||
|         /> | ||||
|     ); | ||||
| } | ||||
|  | ||||
| export { Skeleton }; | ||||
| @@ -14,6 +14,7 @@ type ToasterToast = ToastProps & { | ||||
|     layout?: 'row' | 'column'; | ||||
| }; | ||||
|  | ||||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||||
| const actionTypes = { | ||||
|     ADD_TOAST: 'ADD_TOAST', | ||||
|     UPDATE_TOAST: 'UPDATE_TOAST', | ||||
|   | ||||
							
								
								
									
										15
									
								
								src/context/alert-context/alert-context.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,15 @@ | ||||
| import { createContext, useContext } from 'react'; | ||||
| import { emptyFn } from '@/lib/utils'; | ||||
| import type { BaseAlertDialogProps } from '@/dialogs/base-alert-dialog/base-alert-dialog'; | ||||
|  | ||||
| export interface AlertContext { | ||||
|     showAlert: (params: BaseAlertDialogProps) => void; | ||||
|     closeAlert: () => void; | ||||
| } | ||||
|  | ||||
| export const alertContext = createContext<AlertContext>({ | ||||
|     closeAlert: emptyFn, | ||||
|     showAlert: emptyFn, | ||||
| }); | ||||
|  | ||||
| export const useAlert = () => useContext(alertContext); | ||||
							
								
								
									
										36
									
								
								src/context/alert-context/alert-provider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,36 @@ | ||||
| import React, { useCallback, useState } from 'react'; | ||||
| import type { AlertContext } from './alert-context'; | ||||
| import { alertContext } from './alert-context'; | ||||
| import type { BaseAlertDialogProps } from '@/dialogs/base-alert-dialog/base-alert-dialog'; | ||||
| import { BaseAlertDialog } from '@/dialogs/base-alert-dialog/base-alert-dialog'; | ||||
|  | ||||
| export const AlertProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|     children, | ||||
| }) => { | ||||
|     const [showAlert, setShowAlert] = useState(false); | ||||
|     const [alertParams, setAlertParams] = useState<BaseAlertDialogProps>({ | ||||
|         title: '', | ||||
|     }); | ||||
|     const showAlertHandler: AlertContext['showAlert'] = useCallback( | ||||
|         (params) => { | ||||
|             setAlertParams(params); | ||||
|             setShowAlert(true); | ||||
|         }, | ||||
|         [setShowAlert, setAlertParams] | ||||
|     ); | ||||
|     const closeAlertHandler = useCallback(() => { | ||||
|         setShowAlert(false); | ||||
|     }, [setShowAlert]); | ||||
|  | ||||
|     return ( | ||||
|         <alertContext.Provider | ||||
|             value={{ | ||||
|                 showAlert: showAlertHandler, | ||||
|                 closeAlert: closeAlertHandler, | ||||
|             }} | ||||
|         > | ||||
|             {children} | ||||
|             <BaseAlertDialog dialog={{ open: showAlert }} {...alertParams} /> | ||||
|         </alertContext.Provider> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										22
									
								
								src/context/canvas-context/canvas-context.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,22 @@ | ||||
| import { createContext } from 'react'; | ||||
| import { emptyFn } from '@/lib/utils'; | ||||
| import type { Graph } from '@/lib/graph'; | ||||
| import { createGraph } from '@/lib/graph'; | ||||
|  | ||||
| export interface CanvasContext { | ||||
|     reorderTables: (options?: { updateHistory?: boolean }) => void; | ||||
|     fitView: (options?: { | ||||
|         duration?: number; | ||||
|         padding?: number; | ||||
|         maxZoom?: number; | ||||
|     }) => void; | ||||
|     setOverlapGraph: (graph: Graph<string>) => void; | ||||
|     overlapGraph: Graph<string>; | ||||
| } | ||||
|  | ||||
| export const canvasContext = createContext<CanvasContext>({ | ||||
|     reorderTables: emptyFn, | ||||
|     fitView: emptyFn, | ||||
|     setOverlapGraph: emptyFn, | ||||
|     overlapGraph: createGraph(), | ||||
| }); | ||||
							
								
								
									
										85
									
								
								src/context/canvas-context/canvas-provider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,85 @@ | ||||
| import React, { type ReactNode, useCallback, useState } from 'react'; | ||||
| import { canvasContext } from './canvas-context'; | ||||
| import { useChartDB } from '@/hooks/use-chartdb'; | ||||
| import { | ||||
|     adjustTablePositions, | ||||
|     shouldShowTablesBySchemaFilter, | ||||
| } from '@/lib/domain/db-table'; | ||||
| import { useReactFlow } from '@xyflow/react'; | ||||
| import { findOverlappingTables } from '@/pages/editor-page/canvas/canvas-utils'; | ||||
| import type { Graph } from '@/lib/graph'; | ||||
| import { createGraph } from '@/lib/graph'; | ||||
|  | ||||
| interface CanvasProviderProps { | ||||
|     children: ReactNode; | ||||
| } | ||||
|  | ||||
| export const CanvasProvider = ({ children }: CanvasProviderProps) => { | ||||
|     const { tables, relationships, updateTablesState, filteredSchemas } = | ||||
|         useChartDB(); | ||||
|     const { fitView } = useReactFlow(); | ||||
|     const [overlapGraph, setOverlapGraph] = | ||||
|         useState<Graph<string>>(createGraph()); | ||||
|  | ||||
|     const reorderTables = useCallback( | ||||
|         ( | ||||
|             options: { updateHistory?: boolean } = { | ||||
|                 updateHistory: true, | ||||
|             } | ||||
|         ) => { | ||||
|             const newTables = adjustTablePositions({ | ||||
|                 relationships, | ||||
|                 tables: tables.filter((table) => | ||||
|                     shouldShowTablesBySchemaFilter(table, filteredSchemas) | ||||
|                 ), | ||||
|                 mode: 'all', // Use 'all' mode for manual reordering | ||||
|             }); | ||||
|  | ||||
|             const updatedOverlapGraph = findOverlappingTables({ | ||||
|                 tables: newTables, | ||||
|             }); | ||||
|  | ||||
|             updateTablesState( | ||||
|                 (currentTables) => | ||||
|                     currentTables.map((table) => { | ||||
|                         const newTable = newTables.find( | ||||
|                             (t) => t.id === table.id | ||||
|                         ); | ||||
|                         return { | ||||
|                             id: table.id, | ||||
|                             x: newTable?.x ?? table.x, | ||||
|                             y: newTable?.y ?? table.y, | ||||
|                         }; | ||||
|                     }), | ||||
|                 { | ||||
|                     updateHistory: options.updateHistory ?? true, | ||||
|                     forceOverride: false, | ||||
|                 } | ||||
|             ); | ||||
|  | ||||
|             setOverlapGraph(updatedOverlapGraph); | ||||
|  | ||||
|             setTimeout(() => { | ||||
|                 fitView({ | ||||
|                     duration: 500, | ||||
|                     padding: 0.2, | ||||
|                     maxZoom: 0.8, | ||||
|                 }); | ||||
|             }, 500); | ||||
|         }, | ||||
|         [filteredSchemas, relationships, tables, updateTablesState, fitView] | ||||
|     ); | ||||
|  | ||||
|     return ( | ||||
|         <canvasContext.Provider | ||||
|             value={{ | ||||
|                 reorderTables, | ||||
|                 fitView, | ||||
|                 setOverlapGraph, | ||||
|                 overlapGraph, | ||||
|             }} | ||||
|         > | ||||
|             {children} | ||||
|         </canvasContext.Provider> | ||||
|     ); | ||||
| }; | ||||
| @@ -84,6 +84,7 @@ export interface ChartDBContext { | ||||
|         options?: { updateHistory: boolean } | ||||
|     ) => Promise<void>; | ||||
|     loadDiagram: (diagramId: string) => Promise<Diagram | undefined>; | ||||
|     loadDiagramFromData: (diagram: Diagram) => void; | ||||
|     updateDiagramUpdatedAt: () => Promise<void>; | ||||
|     clearDiagramData: () => Promise<void>; | ||||
|     deleteDiagram: () => Promise<void>; | ||||
| @@ -246,6 +247,7 @@ export const chartDBContext = createContext<ChartDBContext>({ | ||||
|     updateDiagramName: emptyFn, | ||||
|     updateDiagramUpdatedAt: emptyFn, | ||||
|     loadDiagram: emptyFn, | ||||
|     loadDiagramFromData: emptyFn, | ||||
|     clearDiagramData: emptyFn, | ||||
|     deleteDiagram: emptyFn, | ||||
|  | ||||
|   | ||||
| @@ -22,14 +22,18 @@ import { defaultSchemas } from '@/lib/data/default-schemas'; | ||||
| import { useEventEmitter } from 'ahooks'; | ||||
| import type { DBDependency } from '@/lib/domain/db-dependency'; | ||||
| import { storageInitialValue } from '../storage-context/storage-context'; | ||||
| import { useDiff } from '../diff-context/use-diff'; | ||||
| import type { DiffCalculatedEvent } from '../diff-context/diff-context'; | ||||
|  | ||||
| export interface ChartDBProviderProps { | ||||
|     diagram?: Diagram; | ||||
|     readonly?: boolean; | ||||
| } | ||||
|  | ||||
| export const ChartDBProvider: React.FC< | ||||
|     React.PropsWithChildren<ChartDBProviderProps> | ||||
| > = ({ children, diagram, readonly }) => { | ||||
| > = ({ children, diagram, readonly: readonlyProp }) => { | ||||
|     const { hasDiff } = useDiff(); | ||||
|     let db = useStorage(); | ||||
|     const events = useEventEmitter<ChartDBEvent>(); | ||||
|     const { setSchemasFilter, schemasFilter } = useLocalConfig(); | ||||
| @@ -52,9 +56,33 @@ export const ChartDBProvider: React.FC< | ||||
|     const [dependencies, setDependencies] = useState<DBDependency[]>( | ||||
|         diagram?.dependencies ?? [] | ||||
|     ); | ||||
|     const { events: diffEvents } = useDiff(); | ||||
|  | ||||
|     const diffCalculatedHandler = useCallback((event: DiffCalculatedEvent) => { | ||||
|         const { tablesAdded, fieldsAdded, relationshipsAdded } = event.data; | ||||
|         setTables((tables) => | ||||
|             [...tables, ...(tablesAdded ?? [])].map((table) => { | ||||
|                 const fields = fieldsAdded.get(table.id); | ||||
|                 return fields | ||||
|                     ? { ...table, fields: [...table.fields, ...fields] } | ||||
|                     : table; | ||||
|             }) | ||||
|         ); | ||||
|         setRelationships((relationships) => [ | ||||
|             ...relationships, | ||||
|             ...(relationshipsAdded ?? []), | ||||
|         ]); | ||||
|     }, []); | ||||
|  | ||||
|     diffEvents.useSubscription(diffCalculatedHandler); | ||||
|  | ||||
|     const defaultSchemaName = defaultSchemas[databaseType]; | ||||
|  | ||||
|     const readonly = useMemo( | ||||
|         () => readonlyProp ?? hasDiff ?? false, | ||||
|         [readonlyProp, hasDiff] | ||||
|     ); | ||||
|  | ||||
|     if (readonly) { | ||||
|         db = storageInitialValue; | ||||
|     } | ||||
| @@ -310,6 +338,7 @@ export const ChartDBProvider: React.FC< | ||||
|                 color: randomColor(), | ||||
|                 createdAt: Date.now(), | ||||
|                 isView: false, | ||||
|                 order: tables.length, | ||||
|                 ...attributes, | ||||
|             }; | ||||
|             await addTable(table); | ||||
| @@ -1334,15 +1363,9 @@ export const ChartDBProvider: React.FC< | ||||
|         ] | ||||
|     ); | ||||
|  | ||||
|     const loadDiagram: ChartDBContext['loadDiagram'] = useCallback( | ||||
|         async (diagramId: string) => { | ||||
|             const diagram = await db.getDiagram(diagramId, { | ||||
|                 includeRelationships: true, | ||||
|                 includeTables: true, | ||||
|                 includeDependencies: true, | ||||
|             }); | ||||
|  | ||||
|             if (diagram) { | ||||
|     const loadDiagramFromData: ChartDBContext['loadDiagramFromData'] = | ||||
|         useCallback( | ||||
|             async (diagram) => { | ||||
|                 setDiagramId(diagram.id); | ||||
|                 setDiagramName(diagram.name); | ||||
|                 setDatabaseType(diagram.databaseType); | ||||
| @@ -1354,23 +1377,36 @@ export const ChartDBProvider: React.FC< | ||||
|                 setDiagramUpdatedAt(diagram.updatedAt); | ||||
|  | ||||
|                 events.emit({ action: 'load_diagram', data: { diagram } }); | ||||
|             }, | ||||
|             [ | ||||
|                 setDiagramId, | ||||
|                 setDiagramName, | ||||
|                 setDatabaseType, | ||||
|                 setDatabaseEdition, | ||||
|                 setTables, | ||||
|                 setRelationships, | ||||
|                 setDependencies, | ||||
|                 setDiagramCreatedAt, | ||||
|                 setDiagramUpdatedAt, | ||||
|                 events, | ||||
|             ] | ||||
|         ); | ||||
|  | ||||
|     const loadDiagram: ChartDBContext['loadDiagram'] = useCallback( | ||||
|         async (diagramId: string) => { | ||||
|             const diagram = await db.getDiagram(diagramId, { | ||||
|                 includeRelationships: true, | ||||
|                 includeTables: true, | ||||
|                 includeDependencies: true, | ||||
|             }); | ||||
|  | ||||
|             if (diagram) { | ||||
|                 loadDiagramFromData(diagram); | ||||
|             } | ||||
|  | ||||
|             return diagram; | ||||
|         }, | ||||
|         [ | ||||
|             db, | ||||
|             setDiagramId, | ||||
|             setDiagramName, | ||||
|             setDatabaseType, | ||||
|             setDatabaseEdition, | ||||
|             setTables, | ||||
|             setRelationships, | ||||
|             setDependencies, | ||||
|             setDiagramCreatedAt, | ||||
|             setDiagramUpdatedAt, | ||||
|             events, | ||||
|         ] | ||||
|         [db, loadDiagramFromData] | ||||
|     ); | ||||
|  | ||||
|     return ( | ||||
| @@ -1391,6 +1427,7 @@ export const ChartDBProvider: React.FC< | ||||
|                 updateDiagramId, | ||||
|                 updateDiagramName, | ||||
|                 loadDiagram, | ||||
|                 loadDiagramFromData, | ||||
|                 updateDatabaseType, | ||||
|                 updateDatabaseEdition, | ||||
|                 clearDiagramData, | ||||
|   | ||||
| @@ -1,12 +1,14 @@ | ||||
| import { createContext } from 'react'; | ||||
| import { emptyFn } from '@/lib/utils'; | ||||
| import type { BaseAlertDialogProps } from '@/dialogs/base-alert-dialog/base-alert-dialog'; | ||||
| import type { TableSchemaDialogProps } from '@/dialogs/table-schema-dialog/table-schema-dialog'; | ||||
| 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'; | ||||
| import type { CreateRelationshipDialogProps } from '@/dialogs/create-relationship-dialog/create-relationship-dialog'; | ||||
| import type { ImportDBMLDialogProps } from '@/dialogs/import-dbml-dialog/import-dbml-dialog'; | ||||
| import type { OpenDiagramDialogProps } from '@/dialogs/open-diagram-dialog/open-diagram-dialog'; | ||||
|  | ||||
| export interface DialogContext { | ||||
|     // Create diagram dialog | ||||
| @@ -14,19 +16,19 @@ export interface DialogContext { | ||||
|     closeCreateDiagramDialog: () => void; | ||||
|  | ||||
|     // Open diagram dialog | ||||
|     openOpenDiagramDialog: () => void; | ||||
|     openOpenDiagramDialog: ( | ||||
|         params?: Omit<OpenDiagramDialogProps, 'dialog'> | ||||
|     ) => void; | ||||
|     closeOpenDiagramDialog: () => void; | ||||
|  | ||||
|     // Export SQL dialog | ||||
|     openExportSQLDialog: (params: Omit<ExportSQLDialogProps, 'dialog'>) => void; | ||||
|     closeExportSQLDialog: () => void; | ||||
|  | ||||
|     // Alert dialog | ||||
|     showAlert: (params: BaseAlertDialogProps) => void; | ||||
|     closeAlert: () => void; | ||||
|  | ||||
|     // Create relationship dialog | ||||
|     openCreateRelationshipDialog: () => void; | ||||
|     openCreateRelationshipDialog: ( | ||||
|         params?: Omit<CreateRelationshipDialogProps, 'dialog'> | ||||
|     ) => void; | ||||
|     closeCreateRelationshipDialog: () => void; | ||||
|  | ||||
|     // Import database dialog | ||||
| @@ -62,6 +64,12 @@ export interface DialogContext { | ||||
|         params: Omit<ImportDiagramDialogProps, 'dialog'> | ||||
|     ) => void; | ||||
|     closeImportDiagramDialog: () => void; | ||||
|  | ||||
|     // Import DBML dialog | ||||
|     openImportDBMLDialog: ( | ||||
|         params?: Omit<ImportDBMLDialogProps, 'dialog'> | ||||
|     ) => void; | ||||
|     closeImportDBMLDialog: () => void; | ||||
| } | ||||
|  | ||||
| export const dialogContext = createContext<DialogContext>({ | ||||
| @@ -71,8 +79,6 @@ export const dialogContext = createContext<DialogContext>({ | ||||
|     closeOpenDiagramDialog: emptyFn, | ||||
|     openExportSQLDialog: emptyFn, | ||||
|     closeExportSQLDialog: emptyFn, | ||||
|     closeAlert: emptyFn, | ||||
|     showAlert: emptyFn, | ||||
|     closeCreateRelationshipDialog: emptyFn, | ||||
|     openCreateRelationshipDialog: emptyFn, | ||||
|     openImportDatabaseDialog: emptyFn, | ||||
| @@ -87,4 +93,6 @@ export const dialogContext = createContext<DialogContext>({ | ||||
|     closeExportDiagramDialog: emptyFn, | ||||
|     openImportDiagramDialog: emptyFn, | ||||
|     closeImportDiagramDialog: emptyFn, | ||||
|     openImportDBMLDialog: emptyFn, | ||||
|     closeImportDBMLDialog: emptyFn, | ||||
| }); | ||||
|   | ||||
| @@ -2,12 +2,12 @@ import React, { useCallback, useState } from 'react'; | ||||
| import type { DialogContext } from './dialog-context'; | ||||
| import { dialogContext } from './dialog-context'; | ||||
| import { CreateDiagramDialog } from '@/dialogs/create-diagram-dialog/create-diagram-dialog'; | ||||
| import type { OpenDiagramDialogProps } from '@/dialogs/open-diagram-dialog/open-diagram-dialog'; | ||||
| import { OpenDiagramDialog } from '@/dialogs/open-diagram-dialog/open-diagram-dialog'; | ||||
| import type { ExportSQLDialogProps } from '@/dialogs/export-sql-dialog/export-sql-dialog'; | ||||
| import { ExportSQLDialog } from '@/dialogs/export-sql-dialog/export-sql-dialog'; | ||||
| import { DatabaseType } from '@/lib/domain/database-type'; | ||||
| import type { BaseAlertDialogProps } from '@/dialogs/base-alert-dialog/base-alert-dialog'; | ||||
| import { BaseAlertDialog } from '@/dialogs/base-alert-dialog/base-alert-dialog'; | ||||
| import type { CreateRelationshipDialogProps } from '@/dialogs/create-relationship-dialog/create-relationship-dialog'; | ||||
| import { CreateRelationshipDialog } from '@/dialogs/create-relationship-dialog/create-relationship-dialog'; | ||||
| import type { ImportDatabaseDialogProps } from '@/dialogs/import-database-dialog/import-database-dialog'; | ||||
| import { ImportDatabaseDialog } from '@/dialogs/import-database-dialog/import-database-dialog'; | ||||
| @@ -19,15 +19,39 @@ import type { ExportImageDialogProps } from '@/dialogs/export-image-dialog/expor | ||||
| 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'; | ||||
| import type { ImportDBMLDialogProps } from '@/dialogs/import-dbml-dialog/import-dbml-dialog'; | ||||
| import { ImportDBMLDialog } from '@/dialogs/import-dbml-dialog/import-dbml-dialog'; | ||||
|  | ||||
| export const DialogProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|     children, | ||||
| }) => { | ||||
|     const [openNewDiagramDialog, setOpenNewDiagramDialog] = useState(false); | ||||
|     const [openOpenDiagramDialog, setOpenOpenDiagramDialog] = useState(false); | ||||
|     const [openDiagramDialogParams, setOpenDiagramDialogParams] = | ||||
|         useState<Omit<OpenDiagramDialogProps, 'dialog'>>(); | ||||
|  | ||||
|     const openOpenDiagramDialogHandler: DialogContext['openOpenDiagramDialog'] = | ||||
|         useCallback( | ||||
|             (props) => { | ||||
|                 setOpenDiagramDialogParams(props); | ||||
|                 setOpenOpenDiagramDialog(true); | ||||
|             }, | ||||
|             [setOpenOpenDiagramDialog] | ||||
|         ); | ||||
|  | ||||
|     const [openCreateRelationshipDialog, setOpenCreateRelationshipDialog] = | ||||
|         useState(false); | ||||
|     const [createRelationshipDialogParams, setCreateRelationshipDialogParams] = | ||||
|         useState<Omit<CreateRelationshipDialogProps, 'dialog'>>(); | ||||
|     const openCreateRelationshipDialogHandler: DialogContext['openCreateRelationshipDialog'] = | ||||
|         useCallback( | ||||
|             (params) => { | ||||
|                 setCreateRelationshipDialogParams(params); | ||||
|                 setOpenCreateRelationshipDialog(true); | ||||
|             }, | ||||
|             [setOpenCreateRelationshipDialog] | ||||
|         ); | ||||
|  | ||||
|     const [openStarUsDialog, setOpenStarUsDialog] = useState(false); | ||||
|  | ||||
|     // Export image dialog | ||||
| @@ -88,7 +112,7 @@ export const DialogProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|             [setOpenTableSchemaDialog] | ||||
|         ); | ||||
|  | ||||
|     // Export image dialog | ||||
|     // Export diagram dialog | ||||
|     const [openExportDiagramDialog, setOpenExportDiagramDialog] = | ||||
|         useState(false); | ||||
|  | ||||
| @@ -96,35 +120,22 @@ export const DialogProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|     const [openImportDiagramDialog, setOpenImportDiagramDialog] = | ||||
|         useState(false); | ||||
|  | ||||
|     // Alert dialog | ||||
|     const [showAlert, setShowAlert] = useState(false); | ||||
|     const [alertParams, setAlertParams] = useState<BaseAlertDialogProps>({ | ||||
|         title: '', | ||||
|     }); | ||||
|     const showAlertHandler: DialogContext['showAlert'] = useCallback( | ||||
|         (params) => { | ||||
|             setAlertParams(params); | ||||
|             setShowAlert(true); | ||||
|         }, | ||||
|         [setShowAlert, setAlertParams] | ||||
|     ); | ||||
|     const closeAlertHandler = useCallback(() => { | ||||
|         setShowAlert(false); | ||||
|     }, [setShowAlert]); | ||||
|     // Import DBML dialog | ||||
|     const [openImportDBMLDialog, setOpenImportDBMLDialog] = useState(false); | ||||
|     const [importDBMLDialogParams, setImportDBMLDialogParams] = | ||||
|         useState<Omit<ImportDBMLDialogProps, 'dialog'>>(); | ||||
|  | ||||
|     return ( | ||||
|         <dialogContext.Provider | ||||
|             value={{ | ||||
|                 openCreateDiagramDialog: () => setOpenNewDiagramDialog(true), | ||||
|                 closeCreateDiagramDialog: () => setOpenNewDiagramDialog(false), | ||||
|                 openOpenDiagramDialog: () => setOpenOpenDiagramDialog(true), | ||||
|                 openOpenDiagramDialog: openOpenDiagramDialogHandler, | ||||
|                 closeOpenDiagramDialog: () => setOpenOpenDiagramDialog(false), | ||||
|                 openExportSQLDialog: openExportSQLDialogHandler, | ||||
|                 closeExportSQLDialog: () => setOpenExportSQLDialog(false), | ||||
|                 showAlert: showAlertHandler, | ||||
|                 closeAlert: closeAlertHandler, | ||||
|                 openCreateRelationshipDialog: () => | ||||
|                     setOpenCreateRelationshipDialog(true), | ||||
|                 openCreateRelationshipDialog: | ||||
|                     openCreateRelationshipDialogHandler, | ||||
|                 closeCreateRelationshipDialog: () => | ||||
|                     setOpenCreateRelationshipDialog(false), | ||||
|                 openImportDatabaseDialog: openImportDatabaseDialogHandler, | ||||
| @@ -142,18 +153,26 @@ export const DialogProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|                 openImportDiagramDialog: () => setOpenImportDiagramDialog(true), | ||||
|                 closeImportDiagramDialog: () => | ||||
|                     setOpenImportDiagramDialog(false), | ||||
|                 openImportDBMLDialog: (params) => { | ||||
|                     setImportDBMLDialogParams(params); | ||||
|                     setOpenImportDBMLDialog(true); | ||||
|                 }, | ||||
|                 closeImportDBMLDialog: () => setOpenImportDBMLDialog(false), | ||||
|             }} | ||||
|         > | ||||
|             {children} | ||||
|             <CreateDiagramDialog dialog={{ open: openNewDiagramDialog }} /> | ||||
|             <OpenDiagramDialog dialog={{ open: openOpenDiagramDialog }} /> | ||||
|             <OpenDiagramDialog | ||||
|                 dialog={{ open: openOpenDiagramDialog }} | ||||
|                 {...openDiagramDialogParams} | ||||
|             /> | ||||
|             <ExportSQLDialog | ||||
|                 dialog={{ open: openExportSQLDialog }} | ||||
|                 {...exportSQLDialogParams} | ||||
|             /> | ||||
|             <BaseAlertDialog dialog={{ open: showAlert }} {...alertParams} /> | ||||
|             <CreateRelationshipDialog | ||||
|                 dialog={{ open: openCreateRelationshipDialog }} | ||||
|                 {...createRelationshipDialogParams} | ||||
|             /> | ||||
|             <ImportDatabaseDialog | ||||
|                 dialog={{ open: openImportDatabaseDialog }} | ||||
| @@ -170,6 +189,10 @@ export const DialogProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|             /> | ||||
|             <ExportDiagramDialog dialog={{ open: openExportDiagramDialog }} /> | ||||
|             <ImportDiagramDialog dialog={{ open: openImportDiagramDialog }} /> | ||||
|             <ImportDBMLDialog | ||||
|                 dialog={{ open: openImportDBMLDialog }} | ||||
|                 {...importDBMLDialogParams} | ||||
|             /> | ||||
|         </dialogContext.Provider> | ||||
|     ); | ||||
| }; | ||||
|   | ||||
							
								
								
									
										455
									
								
								src/context/diff-context/diff-check/diff-check.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,455 @@ | ||||
| import type { Diagram } from '@/lib/domain/diagram'; | ||||
| import type { DBField } from '@/lib/domain/db-field'; | ||||
| import type { DBIndex } from '@/lib/domain/db-index'; | ||||
| import type { ChartDBDiff, DiffMap, DiffObject } from '@/lib/domain/diff/diff'; | ||||
| import type { FieldDiffAttribute } from '@/lib/domain/diff/field-diff'; | ||||
|  | ||||
| export function getDiffMapKey({ | ||||
|     diffObject, | ||||
|     objectId, | ||||
|     attribute, | ||||
| }: { | ||||
|     diffObject: DiffObject; | ||||
|     objectId: string; | ||||
|     attribute?: string; | ||||
| }): string { | ||||
|     return attribute | ||||
|         ? `${diffObject}-${attribute}-${objectId}` | ||||
|         : `${diffObject}-${objectId}`; | ||||
| } | ||||
|  | ||||
| export function generateDiff({ | ||||
|     diagram, | ||||
|     newDiagram, | ||||
| }: { | ||||
|     diagram: Diagram; | ||||
|     newDiagram: Diagram; | ||||
| }): { | ||||
|     diffMap: DiffMap; | ||||
|     changedTables: Map<string, boolean>; | ||||
|     changedFields: Map<string, boolean>; | ||||
| } { | ||||
|     const newDiffs = new Map<string, ChartDBDiff>(); | ||||
|     const changedTables = new Map<string, boolean>(); | ||||
|     const changedFields = new Map<string, boolean>(); | ||||
|  | ||||
|     // Compare tables | ||||
|     compareTables({ diagram, newDiagram, diffMap: newDiffs, changedTables }); | ||||
|  | ||||
|     // Compare fields and indexes for matching tables | ||||
|     compareTableContents({ | ||||
|         diagram, | ||||
|         newDiagram, | ||||
|         diffMap: newDiffs, | ||||
|         changedTables, | ||||
|         changedFields, | ||||
|     }); | ||||
|  | ||||
|     // Compare relationships | ||||
|     compareRelationships({ diagram, newDiagram, diffMap: newDiffs }); | ||||
|  | ||||
|     return { diffMap: newDiffs, changedTables, changedFields }; | ||||
| } | ||||
|  | ||||
| // Compare tables between diagrams | ||||
| function compareTables({ | ||||
|     diagram, | ||||
|     newDiagram, | ||||
|     diffMap, | ||||
|     changedTables, | ||||
| }: { | ||||
|     diagram: Diagram; | ||||
|     newDiagram: Diagram; | ||||
|     diffMap: DiffMap; | ||||
|     changedTables: Map<string, boolean>; | ||||
| }) { | ||||
|     const oldTables = diagram.tables || []; | ||||
|     const newTables = newDiagram.tables || []; | ||||
|  | ||||
|     // Check for added tables | ||||
|     for (const newTable of newTables) { | ||||
|         if (!oldTables.find((t) => t.id === newTable.id)) { | ||||
|             diffMap.set( | ||||
|                 getDiffMapKey({ diffObject: 'table', objectId: newTable.id }), | ||||
|                 { | ||||
|                     object: 'table', | ||||
|                     type: 'added', | ||||
|                     tableAdded: newTable, | ||||
|                 } | ||||
|             ); | ||||
|             changedTables.set(newTable.id, true); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Check for removed tables | ||||
|     for (const oldTable of oldTables) { | ||||
|         if (!newTables.find((t) => t.id === oldTable.id)) { | ||||
|             diffMap.set( | ||||
|                 getDiffMapKey({ diffObject: 'table', objectId: oldTable.id }), | ||||
|                 { | ||||
|                     object: 'table', | ||||
|                     type: 'removed', | ||||
|                     tableId: oldTable.id, | ||||
|                 } | ||||
|             ); | ||||
|             changedTables.set(oldTable.id, true); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Check for table name, comments and color changes | ||||
|     for (const oldTable of oldTables) { | ||||
|         const newTable = newTables.find((t) => t.id === oldTable.id); | ||||
|  | ||||
|         if (!newTable) continue; | ||||
|  | ||||
|         if (oldTable.name !== newTable.name) { | ||||
|             diffMap.set( | ||||
|                 getDiffMapKey({ | ||||
|                     diffObject: 'table', | ||||
|                     objectId: oldTable.id, | ||||
|                     attribute: 'name', | ||||
|                 }), | ||||
|                 { | ||||
|                     object: 'table', | ||||
|                     type: 'changed', | ||||
|                     tableId: oldTable.id, | ||||
|                     attribute: 'name', | ||||
|                     newValue: newTable.name, | ||||
|                     oldValue: oldTable.name, | ||||
|                 } | ||||
|             ); | ||||
|  | ||||
|             changedTables.set(oldTable.id, true); | ||||
|         } | ||||
|  | ||||
|         if ( | ||||
|             (oldTable.comments || newTable.comments) && | ||||
|             oldTable.comments !== newTable.comments | ||||
|         ) { | ||||
|             diffMap.set( | ||||
|                 getDiffMapKey({ | ||||
|                     diffObject: 'table', | ||||
|                     objectId: oldTable.id, | ||||
|                     attribute: 'comments', | ||||
|                 }), | ||||
|                 { | ||||
|                     object: 'table', | ||||
|                     type: 'changed', | ||||
|                     tableId: oldTable.id, | ||||
|                     attribute: 'comments', | ||||
|                     newValue: newTable.comments, | ||||
|                     oldValue: oldTable.comments, | ||||
|                 } | ||||
|             ); | ||||
|  | ||||
|             changedTables.set(oldTable.id, true); | ||||
|         } | ||||
|  | ||||
|         if (oldTable.color !== newTable.color) { | ||||
|             diffMap.set( | ||||
|                 getDiffMapKey({ | ||||
|                     diffObject: 'table', | ||||
|                     objectId: oldTable.id, | ||||
|                     attribute: 'color', | ||||
|                 }), | ||||
|                 { | ||||
|                     object: 'table', | ||||
|                     type: 'changed', | ||||
|                     tableId: oldTable.id, | ||||
|                     attribute: 'color', | ||||
|                     newValue: newTable.color, | ||||
|                     oldValue: oldTable.color, | ||||
|                 } | ||||
|             ); | ||||
|  | ||||
|             changedTables.set(oldTable.id, true); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Compare fields and indexes for matching tables | ||||
| function compareTableContents({ | ||||
|     diagram, | ||||
|     newDiagram, | ||||
|     diffMap, | ||||
|     changedTables, | ||||
|     changedFields, | ||||
| }: { | ||||
|     diagram: Diagram; | ||||
|     newDiagram: Diagram; | ||||
|     diffMap: DiffMap; | ||||
|     changedTables: Map<string, boolean>; | ||||
|     changedFields: Map<string, boolean>; | ||||
| }) { | ||||
|     const oldTables = diagram.tables || []; | ||||
|     const newTables = newDiagram.tables || []; | ||||
|  | ||||
|     // For each table that exists in both diagrams | ||||
|     for (const oldTable of oldTables) { | ||||
|         const newTable = newTables.find((t) => t.id === oldTable.id); | ||||
|         if (!newTable) continue; | ||||
|  | ||||
|         // Compare fields | ||||
|         compareFields({ | ||||
|             tableId: oldTable.id, | ||||
|             oldFields: oldTable.fields, | ||||
|             newFields: newTable.fields, | ||||
|             diffMap, | ||||
|             changedTables, | ||||
|             changedFields, | ||||
|         }); | ||||
|  | ||||
|         // Compare indexes | ||||
|         compareIndexes({ | ||||
|             tableId: oldTable.id, | ||||
|             oldIndexes: oldTable.indexes, | ||||
|             newIndexes: newTable.indexes, | ||||
|             diffMap, | ||||
|             changedTables, | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Compare fields between tables | ||||
| function compareFields({ | ||||
|     tableId, | ||||
|     oldFields, | ||||
|     newFields, | ||||
|     diffMap, | ||||
|     changedTables, | ||||
|     changedFields, | ||||
| }: { | ||||
|     tableId: string; | ||||
|     oldFields: DBField[]; | ||||
|     newFields: DBField[]; | ||||
|     diffMap: DiffMap; | ||||
|     changedTables: Map<string, boolean>; | ||||
|     changedFields: Map<string, boolean>; | ||||
| }) { | ||||
|     // Check for added fields | ||||
|     for (const newField of newFields) { | ||||
|         if (!oldFields.find((f) => f.id === newField.id)) { | ||||
|             diffMap.set( | ||||
|                 getDiffMapKey({ | ||||
|                     diffObject: 'field', | ||||
|                     objectId: newField.id, | ||||
|                 }), | ||||
|                 { | ||||
|                     object: 'field', | ||||
|                     type: 'added', | ||||
|                     newField, | ||||
|                     tableId, | ||||
|                 } | ||||
|             ); | ||||
|             changedTables.set(tableId, true); | ||||
|             changedFields.set(newField.id, true); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Check for removed fields | ||||
|     for (const oldField of oldFields) { | ||||
|         if (!newFields.find((f) => f.id === oldField.id)) { | ||||
|             diffMap.set( | ||||
|                 getDiffMapKey({ | ||||
|                     diffObject: 'field', | ||||
|                     objectId: oldField.id, | ||||
|                 }), | ||||
|                 { | ||||
|                     object: 'field', | ||||
|                     type: 'removed', | ||||
|                     fieldId: oldField.id, | ||||
|                     tableId, | ||||
|                 } | ||||
|             ); | ||||
|  | ||||
|             changedTables.set(tableId, true); | ||||
|             changedFields.set(oldField.id, true); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Check for field changes | ||||
|     for (const oldField of oldFields) { | ||||
|         const newField = newFields.find((f) => f.id === oldField.id); | ||||
|         if (!newField) continue; | ||||
|  | ||||
|         // Compare basic field properties | ||||
|         compareFieldProperties({ | ||||
|             tableId, | ||||
|             oldField, | ||||
|             newField, | ||||
|             diffMap, | ||||
|             changedTables, | ||||
|             changedFields, | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Compare field properties | ||||
| function compareFieldProperties({ | ||||
|     tableId, | ||||
|     oldField, | ||||
|     newField, | ||||
|     diffMap, | ||||
|     changedTables, | ||||
|     changedFields, | ||||
| }: { | ||||
|     tableId: string; | ||||
|     oldField: DBField; | ||||
|     newField: DBField; | ||||
|     diffMap: DiffMap; | ||||
|     changedTables: Map<string, boolean>; | ||||
|     changedFields: Map<string, boolean>; | ||||
| }) { | ||||
|     const changedAttributes: FieldDiffAttribute[] = []; | ||||
|  | ||||
|     if (oldField.name !== newField.name) { | ||||
|         changedAttributes.push('name'); | ||||
|     } | ||||
|  | ||||
|     if (oldField.type.id !== newField.type.id) { | ||||
|         changedAttributes.push('type'); | ||||
|     } | ||||
|  | ||||
|     if (oldField.primaryKey !== newField.primaryKey) { | ||||
|         changedAttributes.push('primaryKey'); | ||||
|     } | ||||
|  | ||||
|     if (oldField.unique !== newField.unique) { | ||||
|         changedAttributes.push('unique'); | ||||
|     } | ||||
|  | ||||
|     if (oldField.nullable !== newField.nullable) { | ||||
|         changedAttributes.push('nullable'); | ||||
|     } | ||||
|  | ||||
|     if ( | ||||
|         (newField.comments || oldField.comments) && | ||||
|         oldField.comments !== newField.comments | ||||
|     ) { | ||||
|         changedAttributes.push('comments'); | ||||
|     } | ||||
|  | ||||
|     if (changedAttributes.length > 0) { | ||||
|         for (const attribute of changedAttributes) { | ||||
|             diffMap.set( | ||||
|                 getDiffMapKey({ | ||||
|                     diffObject: 'field', | ||||
|                     objectId: oldField.id, | ||||
|                     attribute, | ||||
|                 }), | ||||
|                 { | ||||
|                     object: 'field', | ||||
|                     type: 'changed', | ||||
|                     fieldId: oldField.id, | ||||
|                     tableId, | ||||
|                     attribute, | ||||
|                     oldValue: oldField[attribute] ?? '', | ||||
|                     newValue: newField[attribute] ?? '', | ||||
|                 } | ||||
|             ); | ||||
|         } | ||||
|         changedTables.set(tableId, true); | ||||
|         changedFields.set(oldField.id, true); | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Compare indexes between tables | ||||
| function compareIndexes({ | ||||
|     tableId, | ||||
|     oldIndexes, | ||||
|     newIndexes, | ||||
|     diffMap, | ||||
|     changedTables, | ||||
| }: { | ||||
|     tableId: string; | ||||
|     oldIndexes: DBIndex[]; | ||||
|     newIndexes: DBIndex[]; | ||||
|     diffMap: DiffMap; | ||||
|     changedTables: Map<string, boolean>; | ||||
| }) { | ||||
|     // Check for added indexes | ||||
|     for (const newIndex of newIndexes) { | ||||
|         if (!oldIndexes.find((i) => i.id === newIndex.id)) { | ||||
|             diffMap.set( | ||||
|                 getDiffMapKey({ | ||||
|                     diffObject: 'index', | ||||
|                     objectId: newIndex.id, | ||||
|                 }), | ||||
|                 { | ||||
|                     object: 'index', | ||||
|                     type: 'added', | ||||
|                     newIndex, | ||||
|                     tableId, | ||||
|                 } | ||||
|             ); | ||||
|             changedTables.set(tableId, true); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Check for removed indexes | ||||
|     for (const oldIndex of oldIndexes) { | ||||
|         if (!newIndexes.find((i) => i.id === oldIndex.id)) { | ||||
|             diffMap.set( | ||||
|                 getDiffMapKey({ | ||||
|                     diffObject: 'index', | ||||
|                     objectId: oldIndex.id, | ||||
|                 }), | ||||
|                 { | ||||
|                     object: 'index', | ||||
|                     type: 'removed', | ||||
|                     indexId: oldIndex.id, | ||||
|                     tableId, | ||||
|                 } | ||||
|             ); | ||||
|             changedTables.set(tableId, true); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Compare relationships between diagrams | ||||
| function compareRelationships({ | ||||
|     diagram, | ||||
|     newDiagram, | ||||
|     diffMap, | ||||
| }: { | ||||
|     diagram: Diagram; | ||||
|     newDiagram: Diagram; | ||||
|     diffMap: DiffMap; | ||||
| }) { | ||||
|     const oldRelationships = diagram.relationships || []; | ||||
|     const newRelationships = newDiagram.relationships || []; | ||||
|  | ||||
|     // Check for added relationships | ||||
|     for (const newRelationship of newRelationships) { | ||||
|         if (!oldRelationships.find((r) => r.id === newRelationship.id)) { | ||||
|             diffMap.set( | ||||
|                 getDiffMapKey({ | ||||
|                     diffObject: 'relationship', | ||||
|                     objectId: newRelationship.id, | ||||
|                 }), | ||||
|                 { | ||||
|                     object: 'relationship', | ||||
|                     type: 'added', | ||||
|                     newRelationship, | ||||
|                 } | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Check for removed relationships | ||||
|     for (const oldRelationship of oldRelationships) { | ||||
|         if (!newRelationships.find((r) => r.id === oldRelationship.id)) { | ||||
|             diffMap.set( | ||||
|                 getDiffMapKey({ | ||||
|                     diffObject: 'relationship', | ||||
|                     objectId: oldRelationship.id, | ||||
|                 }), | ||||
|                 { | ||||
|                     object: 'relationship', | ||||
|                     type: 'removed', | ||||
|                     relationshipId: oldRelationship.id, | ||||
|                 } | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										79
									
								
								src/context/diff-context/diff-context.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,79 @@ | ||||
| import { createContext } from 'react'; | ||||
| import type { Diagram } from '@/lib/domain/diagram'; | ||||
| import type { DBTable } from '@/lib/domain/db-table'; | ||||
| import type { EventEmitter } from 'ahooks/lib/useEventEmitter'; | ||||
| import type { DBField } from '@/lib/domain/db-field'; | ||||
| import type { DataType } from '@/lib/data/data-types/data-types'; | ||||
| import type { DBRelationship } from '@/lib/domain/db-relationship'; | ||||
| import type { DiffMap } from '@/lib/domain/diff/diff'; | ||||
|  | ||||
| export type DiffEventType = 'diff_calculated'; | ||||
|  | ||||
| export type DiffEventBase<T extends DiffEventType, D> = { | ||||
|     action: T; | ||||
|     data: D; | ||||
| }; | ||||
|  | ||||
| export type DiffCalculatedData = { | ||||
|     tablesAdded: DBTable[]; | ||||
|     fieldsAdded: Map<string, DBField[]>; | ||||
|     relationshipsAdded: DBRelationship[]; | ||||
| }; | ||||
|  | ||||
| export type DiffCalculatedEvent = DiffEventBase< | ||||
|     'diff_calculated', | ||||
|     DiffCalculatedData | ||||
| >; | ||||
|  | ||||
| export type DiffEvent = DiffCalculatedEvent; | ||||
|  | ||||
| export interface DiffContext { | ||||
|     newDiagram: Diagram | null; | ||||
|     originalDiagram: Diagram | null; | ||||
|     diffMap: DiffMap; | ||||
|     hasDiff: boolean; | ||||
|  | ||||
|     calculateDiff: ({ | ||||
|         diagram, | ||||
|         newDiagram, | ||||
|     }: { | ||||
|         diagram: Diagram; | ||||
|         newDiagram: Diagram; | ||||
|     }) => void; | ||||
|  | ||||
|     // table diff | ||||
|     checkIfTableHasChange: ({ tableId }: { tableId: string }) => boolean; | ||||
|     checkIfNewTable: ({ tableId }: { tableId: string }) => boolean; | ||||
|     checkIfTableRemoved: ({ tableId }: { tableId: string }) => boolean; | ||||
|     getTableNewName: ({ tableId }: { tableId: string }) => string | null; | ||||
|     getTableNewColor: ({ tableId }: { tableId: string }) => string | null; | ||||
|  | ||||
|     // field diff | ||||
|     checkIfFieldHasChange: ({ | ||||
|         tableId, | ||||
|         fieldId, | ||||
|     }: { | ||||
|         tableId: string; | ||||
|         fieldId: string; | ||||
|     }) => boolean; | ||||
|     checkIfFieldRemoved: ({ fieldId }: { fieldId: string }) => boolean; | ||||
|     checkIfNewField: ({ fieldId }: { fieldId: string }) => boolean; | ||||
|     getFieldNewName: ({ fieldId }: { fieldId: string }) => string | null; | ||||
|     getFieldNewType: ({ fieldId }: { fieldId: string }) => DataType | null; | ||||
|  | ||||
|     // relationship diff | ||||
|     checkIfNewRelationship: ({ | ||||
|         relationshipId, | ||||
|     }: { | ||||
|         relationshipId: string; | ||||
|     }) => boolean; | ||||
|     checkIfRelationshipRemoved: ({ | ||||
|         relationshipId, | ||||
|     }: { | ||||
|         relationshipId: string; | ||||
|     }) => boolean; | ||||
|  | ||||
|     events: EventEmitter<DiffEvent>; | ||||
| } | ||||
|  | ||||
| export const diffContext = createContext<DiffContext | undefined>(undefined); | ||||
							
								
								
									
										373
									
								
								src/context/diff-context/diff-provider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,373 @@ | ||||
| import React, { useCallback } from 'react'; | ||||
| import type { | ||||
|     DiffCalculatedData, | ||||
|     DiffContext, | ||||
|     DiffEvent, | ||||
| } from './diff-context'; | ||||
| import { diffContext } from './diff-context'; | ||||
|  | ||||
| import { generateDiff, getDiffMapKey } from './diff-check/diff-check'; | ||||
| import type { Diagram } from '@/lib/domain/diagram'; | ||||
| import { useEventEmitter } from 'ahooks'; | ||||
| import type { DBField } from '@/lib/domain/db-field'; | ||||
| import type { DataType } from '@/lib/data/data-types/data-types'; | ||||
| import type { DBRelationship } from '@/lib/domain/db-relationship'; | ||||
| import type { ChartDBDiff, DiffMap } from '@/lib/domain/diff/diff'; | ||||
|  | ||||
| export const DiffProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|     children, | ||||
| }) => { | ||||
|     const [newDiagram, setNewDiagram] = React.useState<Diagram | null>(null); | ||||
|     const [originalDiagram, setOriginalDiagram] = | ||||
|         React.useState<Diagram | null>(null); | ||||
|     const [diffMap, setDiffMap] = React.useState<DiffMap>( | ||||
|         new Map<string, ChartDBDiff>() | ||||
|     ); | ||||
|     const [tablesChanged, setTablesChanged] = React.useState< | ||||
|         Map<string, boolean> | ||||
|     >(new Map<string, boolean>()); | ||||
|     const [fieldsChanged, setFieldsChanged] = React.useState< | ||||
|         Map<string, boolean> | ||||
|     >(new Map<string, boolean>()); | ||||
|  | ||||
|     const events = useEventEmitter<DiffEvent>(); | ||||
|  | ||||
|     const generateNewFieldsMap = useCallback( | ||||
|         ({ | ||||
|             diffMap, | ||||
|             newDiagram, | ||||
|         }: { | ||||
|             diffMap: DiffMap; | ||||
|             newDiagram: Diagram; | ||||
|         }) => { | ||||
|             const newFieldsMap = new Map<string, DBField[]>(); | ||||
|  | ||||
|             diffMap.forEach((diff) => { | ||||
|                 if (diff.object === 'field' && diff.type === 'added') { | ||||
|                     const field = newDiagram?.tables | ||||
|                         ?.find((table) => table.id === diff.tableId) | ||||
|                         ?.fields.find((f) => f.id === diff.newField.id); | ||||
|  | ||||
|                     if (field) { | ||||
|                         newFieldsMap.set(diff.tableId, [ | ||||
|                             ...(newFieldsMap.get(diff.tableId) ?? []), | ||||
|                             field, | ||||
|                         ]); | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
|  | ||||
|             return newFieldsMap; | ||||
|         }, | ||||
|         [] | ||||
|     ); | ||||
|  | ||||
|     const findNewRelationships = useCallback( | ||||
|         ({ | ||||
|             diffMap, | ||||
|             newDiagram, | ||||
|         }: { | ||||
|             diffMap: DiffMap; | ||||
|             newDiagram: Diagram; | ||||
|         }) => { | ||||
|             const relationships: DBRelationship[] = []; | ||||
|             diffMap.forEach((diff) => { | ||||
|                 if (diff.object === 'relationship' && diff.type === 'added') { | ||||
|                     const relationship = newDiagram?.relationships?.find( | ||||
|                         (rel) => rel.id === diff.newRelationship.id | ||||
|                     ); | ||||
|  | ||||
|                     if (relationship) { | ||||
|                         relationships.push(relationship); | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
|  | ||||
|             return relationships; | ||||
|         }, | ||||
|         [] | ||||
|     ); | ||||
|  | ||||
|     const generateDiffCalculatedData = useCallback( | ||||
|         ({ | ||||
|             newDiagram, | ||||
|             diffMap, | ||||
|         }: { | ||||
|             newDiagram: Diagram; | ||||
|             diffMap: DiffMap; | ||||
|         }): DiffCalculatedData => { | ||||
|             return { | ||||
|                 tablesAdded: | ||||
|                     newDiagram?.tables?.filter((table) => { | ||||
|                         const tableKey = getDiffMapKey({ | ||||
|                             diffObject: 'table', | ||||
|                             objectId: table.id, | ||||
|                         }); | ||||
|  | ||||
|                         return ( | ||||
|                             diffMap.has(tableKey) && | ||||
|                             diffMap.get(tableKey)?.type === 'added' | ||||
|                         ); | ||||
|                     }) ?? [], | ||||
|  | ||||
|                 fieldsAdded: generateNewFieldsMap({ | ||||
|                     diffMap: diffMap, | ||||
|                     newDiagram: newDiagram, | ||||
|                 }), | ||||
|                 relationshipsAdded: findNewRelationships({ | ||||
|                     diffMap: diffMap, | ||||
|                     newDiagram: newDiagram, | ||||
|                 }), | ||||
|             }; | ||||
|         }, | ||||
|         [findNewRelationships, generateNewFieldsMap] | ||||
|     ); | ||||
|  | ||||
|     const calculateDiff: DiffContext['calculateDiff'] = useCallback( | ||||
|         ({ diagram, newDiagram: newDiagramArg }) => { | ||||
|             const { | ||||
|                 diffMap: newDiffs, | ||||
|                 changedTables: newChangedTables, | ||||
|                 changedFields: newChangedFields, | ||||
|             } = generateDiff({ diagram, newDiagram: newDiagramArg }); | ||||
|  | ||||
|             setDiffMap(newDiffs); | ||||
|             setTablesChanged(newChangedTables); | ||||
|             setFieldsChanged(newChangedFields); | ||||
|             setNewDiagram(newDiagramArg); | ||||
|             setOriginalDiagram(diagram); | ||||
|  | ||||
|             events.emit({ | ||||
|                 action: 'diff_calculated', | ||||
|                 data: generateDiffCalculatedData({ | ||||
|                     diffMap: newDiffs, | ||||
|                     newDiagram: newDiagramArg, | ||||
|                 }), | ||||
|             }); | ||||
|         }, | ||||
|         [setDiffMap, events, generateDiffCalculatedData] | ||||
|     ); | ||||
|  | ||||
|     const getTableNewName = useCallback<DiffContext['getTableNewName']>( | ||||
|         ({ tableId }) => { | ||||
|             const tableNameKey = getDiffMapKey({ | ||||
|                 diffObject: 'table', | ||||
|                 objectId: tableId, | ||||
|                 attribute: 'name', | ||||
|             }); | ||||
|  | ||||
|             if (diffMap.has(tableNameKey)) { | ||||
|                 const diff = diffMap.get(tableNameKey); | ||||
|  | ||||
|                 if (diff?.type === 'changed') { | ||||
|                     return diff.newValue as string; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return null; | ||||
|         }, | ||||
|         [diffMap] | ||||
|     ); | ||||
|  | ||||
|     const getTableNewColor = useCallback<DiffContext['getTableNewColor']>( | ||||
|         ({ tableId }) => { | ||||
|             const tableColorKey = getDiffMapKey({ | ||||
|                 diffObject: 'table', | ||||
|                 objectId: tableId, | ||||
|                 attribute: 'color', | ||||
|             }); | ||||
|  | ||||
|             if (diffMap.has(tableColorKey)) { | ||||
|                 const diff = diffMap.get(tableColorKey); | ||||
|  | ||||
|                 if (diff?.type === 'changed') { | ||||
|                     return diff.newValue as string; | ||||
|                 } | ||||
|             } | ||||
|             return null; | ||||
|         }, | ||||
|         [diffMap] | ||||
|     ); | ||||
|  | ||||
|     const checkIfTableHasChange = useCallback< | ||||
|         DiffContext['checkIfTableHasChange'] | ||||
|     >(({ tableId }) => tablesChanged.get(tableId) ?? false, [tablesChanged]); | ||||
|  | ||||
|     const checkIfNewTable = useCallback<DiffContext['checkIfNewTable']>( | ||||
|         ({ tableId }) => { | ||||
|             const tableKey = getDiffMapKey({ | ||||
|                 diffObject: 'table', | ||||
|                 objectId: tableId, | ||||
|             }); | ||||
|  | ||||
|             return ( | ||||
|                 diffMap.has(tableKey) && diffMap.get(tableKey)?.type === 'added' | ||||
|             ); | ||||
|         }, | ||||
|         [diffMap] | ||||
|     ); | ||||
|  | ||||
|     const checkIfTableRemoved = useCallback<DiffContext['checkIfTableRemoved']>( | ||||
|         ({ tableId }) => { | ||||
|             const tableKey = getDiffMapKey({ | ||||
|                 diffObject: 'table', | ||||
|                 objectId: tableId, | ||||
|             }); | ||||
|  | ||||
|             return ( | ||||
|                 diffMap.has(tableKey) && | ||||
|                 diffMap.get(tableKey)?.type === 'removed' | ||||
|             ); | ||||
|         }, | ||||
|         [diffMap] | ||||
|     ); | ||||
|  | ||||
|     const checkIfFieldHasChange = useCallback< | ||||
|         DiffContext['checkIfFieldHasChange'] | ||||
|     >( | ||||
|         ({ fieldId }) => { | ||||
|             return fieldsChanged.get(fieldId) ?? false; | ||||
|         }, | ||||
|         [fieldsChanged] | ||||
|     ); | ||||
|  | ||||
|     const checkIfFieldRemoved = useCallback<DiffContext['checkIfFieldRemoved']>( | ||||
|         ({ fieldId }) => { | ||||
|             const fieldKey = getDiffMapKey({ | ||||
|                 diffObject: 'field', | ||||
|                 objectId: fieldId, | ||||
|             }); | ||||
|  | ||||
|             return ( | ||||
|                 diffMap.has(fieldKey) && | ||||
|                 diffMap.get(fieldKey)?.type === 'removed' | ||||
|             ); | ||||
|         }, | ||||
|         [diffMap] | ||||
|     ); | ||||
|  | ||||
|     const checkIfNewField = useCallback<DiffContext['checkIfNewField']>( | ||||
|         ({ fieldId }) => { | ||||
|             const fieldKey = getDiffMapKey({ | ||||
|                 diffObject: 'field', | ||||
|                 objectId: fieldId, | ||||
|             }); | ||||
|  | ||||
|             return ( | ||||
|                 diffMap.has(fieldKey) && diffMap.get(fieldKey)?.type === 'added' | ||||
|             ); | ||||
|         }, | ||||
|         [diffMap] | ||||
|     ); | ||||
|  | ||||
|     const getFieldNewName = useCallback<DiffContext['getFieldNewName']>( | ||||
|         ({ fieldId }) => { | ||||
|             const fieldKey = getDiffMapKey({ | ||||
|                 diffObject: 'field', | ||||
|                 objectId: fieldId, | ||||
|                 attribute: 'name', | ||||
|             }); | ||||
|  | ||||
|             if (diffMap.has(fieldKey)) { | ||||
|                 const diff = diffMap.get(fieldKey); | ||||
|  | ||||
|                 if (diff?.type === 'changed') { | ||||
|                     return diff.newValue as string; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return null; | ||||
|         }, | ||||
|         [diffMap] | ||||
|     ); | ||||
|  | ||||
|     const getFieldNewType = useCallback<DiffContext['getFieldNewType']>( | ||||
|         ({ fieldId }) => { | ||||
|             const fieldKey = getDiffMapKey({ | ||||
|                 diffObject: 'field', | ||||
|                 objectId: fieldId, | ||||
|                 attribute: 'type', | ||||
|             }); | ||||
|  | ||||
|             if (diffMap.has(fieldKey)) { | ||||
|                 const diff = diffMap.get(fieldKey); | ||||
|  | ||||
|                 if (diff?.type === 'changed') { | ||||
|                     return diff.newValue as DataType; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return null; | ||||
|         }, | ||||
|         [diffMap] | ||||
|     ); | ||||
|  | ||||
|     const checkIfNewRelationship = useCallback< | ||||
|         DiffContext['checkIfNewRelationship'] | ||||
|     >( | ||||
|         ({ relationshipId }) => { | ||||
|             const relationshipKey = getDiffMapKey({ | ||||
|                 diffObject: 'relationship', | ||||
|                 objectId: relationshipId, | ||||
|             }); | ||||
|  | ||||
|             return ( | ||||
|                 diffMap.has(relationshipKey) && | ||||
|                 diffMap.get(relationshipKey)?.type === 'added' | ||||
|             ); | ||||
|         }, | ||||
|         [diffMap] | ||||
|     ); | ||||
|  | ||||
|     const checkIfRelationshipRemoved = useCallback< | ||||
|         DiffContext['checkIfRelationshipRemoved'] | ||||
|     >( | ||||
|         ({ relationshipId }) => { | ||||
|             const relationshipKey = getDiffMapKey({ | ||||
|                 diffObject: 'relationship', | ||||
|                 objectId: relationshipId, | ||||
|             }); | ||||
|  | ||||
|             return ( | ||||
|                 diffMap.has(relationshipKey) && | ||||
|                 diffMap.get(relationshipKey)?.type === 'removed' | ||||
|             ); | ||||
|         }, | ||||
|         [diffMap] | ||||
|     ); | ||||
|  | ||||
|     return ( | ||||
|         <diffContext.Provider | ||||
|             value={{ | ||||
|                 newDiagram, | ||||
|                 originalDiagram, | ||||
|                 diffMap, | ||||
|                 hasDiff: diffMap.size > 0, | ||||
|  | ||||
|                 calculateDiff, | ||||
|  | ||||
|                 // table diff | ||||
|                 getTableNewName, | ||||
|                 checkIfNewTable, | ||||
|                 checkIfTableRemoved, | ||||
|                 checkIfTableHasChange, | ||||
|                 getTableNewColor, | ||||
|  | ||||
|                 // field diff | ||||
|                 checkIfFieldHasChange, | ||||
|                 checkIfFieldRemoved, | ||||
|                 checkIfNewField, | ||||
|                 getFieldNewName, | ||||
|                 getFieldNewType, | ||||
|  | ||||
|                 // relationship diff | ||||
|                 checkIfNewRelationship, | ||||
|                 checkIfRelationshipRemoved, | ||||
|  | ||||
|                 events, | ||||
|             }} | ||||
|         > | ||||
|             {children} | ||||
|         </diffContext.Provider> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										10
									
								
								src/context/diff-context/use-diff.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,10 @@ | ||||
| import { useContext } from 'react'; | ||||
| import { diffContext } from './diff-context'; | ||||
|  | ||||
| export const useDiff = () => { | ||||
|     const context = useContext(diffContext); | ||||
|     if (context === undefined) { | ||||
|         throw new Error('useDiff must be used within an DiffProvider'); | ||||
|     } | ||||
|     return context; | ||||
| }; | ||||
| @@ -1,4 +1,4 @@ | ||||
| import React, { useCallback, useMemo } from 'react'; | ||||
| import React, { useCallback, useMemo, useEffect, useState } from 'react'; | ||||
| import type { ExportImageContext, ImageType } from './export-image-context'; | ||||
| import { exportImageContext } from './export-image-context'; | ||||
| import { toJpeg, toPng, toSvg } from 'html-to-image'; | ||||
| @@ -6,6 +6,8 @@ import { useReactFlow } from '@xyflow/react'; | ||||
| import { useChartDB } from '@/hooks/use-chartdb'; | ||||
| import { useFullScreenLoader } from '@/hooks/use-full-screen-spinner'; | ||||
| import { useTheme } from '@/hooks/use-theme'; | ||||
| import logoDark from '@/assets/logo-dark.png'; | ||||
| import logoLight from '@/assets/logo-light.png'; | ||||
|  | ||||
| export const ExportImageProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|     children, | ||||
| @@ -14,6 +16,24 @@ export const ExportImageProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|     const { setNodes, getViewport } = useReactFlow(); | ||||
|     const { effectiveTheme } = useTheme(); | ||||
|     const { diagramName } = useChartDB(); | ||||
|     const [logoBase64, setLogoBase64] = useState<string>(''); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         // Convert logo to base64 on component mount | ||||
|         const img = new Image(); | ||||
|         img.src = effectiveTheme === 'light' ? logoLight : logoDark; | ||||
|         img.onload = () => { | ||||
|             const canvas = document.createElement('canvas'); | ||||
|             canvas.width = img.width; | ||||
|             canvas.height = img.height; | ||||
|             const ctx = canvas.getContext('2d'); | ||||
|             if (ctx) { | ||||
|                 ctx.drawImage(img, 0, 0); | ||||
|                 const base64 = canvas.toDataURL('image/png'); | ||||
|                 setLogoBase64(base64); | ||||
|             } | ||||
|         }; | ||||
|     }, [effectiveTheme]); | ||||
|  | ||||
|     const downloadImage = useCallback( | ||||
|         (dataUrl: string, type: ImageType) => { | ||||
| @@ -128,16 +148,22 @@ export const ExportImageProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|                     'http://www.w3.org/2000/svg', | ||||
|                     'rect' | ||||
|                 ); | ||||
|                 const padding = 2000; | ||||
|                 backgroundRect.setAttribute('x', String(-viewport.x - padding)); | ||||
|                 backgroundRect.setAttribute('y', String(-viewport.y - padding)); | ||||
|                 const bgPadding = 2000; | ||||
|                 backgroundRect.setAttribute( | ||||
|                     'x', | ||||
|                     String(-viewport.x - bgPadding) | ||||
|                 ); | ||||
|                 backgroundRect.setAttribute( | ||||
|                     'y', | ||||
|                     String(-viewport.y - bgPadding) | ||||
|                 ); | ||||
|                 backgroundRect.setAttribute( | ||||
|                     'width', | ||||
|                     String(reactFlowBounds.width + 2 * padding) | ||||
|                     String(reactFlowBounds.width + 2 * bgPadding) | ||||
|                 ); | ||||
|                 backgroundRect.setAttribute( | ||||
|                     'height', | ||||
|                     String(reactFlowBounds.height + 2 * padding) | ||||
|                     String(reactFlowBounds.height + 2 * bgPadding) | ||||
|                 ); | ||||
|                 backgroundRect.setAttribute('fill', 'url(#background-pattern)'); | ||||
|                 tempSvg.appendChild(backgroundRect); | ||||
| @@ -148,27 +174,110 @@ export const ExportImageProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|                 ); | ||||
|  | ||||
|                 try { | ||||
|                     const dataUrl = await imageCreateFn(viewportElement, { | ||||
|                         ...(type === 'jpeg' || type === 'png' | ||||
|                             ? { | ||||
|                                   backgroundColor: | ||||
|                                       effectiveTheme === 'light' | ||||
|                                           ? '#ffffff' | ||||
|                                           : '#141414', | ||||
|                               } | ||||
|                             : {}), | ||||
|                         width: reactFlowBounds.width, | ||||
|                         height: reactFlowBounds.height, | ||||
|                         style: { | ||||
|                             width: `${reactFlowBounds.width}px`, | ||||
|                             height: `${reactFlowBounds.height}px`, | ||||
|                             transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`, | ||||
|                         }, | ||||
|                         quality: 1, | ||||
|                         pixelRatio: scale, | ||||
|                     }); | ||||
|                     // Handle SVG export differently | ||||
|                     if (type === 'svg') { | ||||
|                         const dataUrl = await imageCreateFn(viewportElement, { | ||||
|                             width: reactFlowBounds.width, | ||||
|                             height: reactFlowBounds.height, | ||||
|                             style: { | ||||
|                                 width: `${reactFlowBounds.width}px`, | ||||
|                                 height: `${reactFlowBounds.height}px`, | ||||
|                                 transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`, | ||||
|                             }, | ||||
|                             quality: 1, | ||||
|                             pixelRatio: scale, | ||||
|                             skipFonts: true, | ||||
|                         }); | ||||
|                         downloadImage(dataUrl, type); | ||||
|                         return; | ||||
|                     } | ||||
|  | ||||
|                     downloadImage(dataUrl, type); | ||||
|                     // For PNG and JPEG, continue with the watermark process | ||||
|                     const initialDataUrl = await imageCreateFn( | ||||
|                         viewportElement, | ||||
|                         { | ||||
|                             backgroundColor: | ||||
|                                 effectiveTheme === 'light' | ||||
|                                     ? '#ffffff' | ||||
|                                     : '#141414', | ||||
|                             width: reactFlowBounds.width, | ||||
|                             height: reactFlowBounds.height, | ||||
|                             style: { | ||||
|                                 width: `${reactFlowBounds.width}px`, | ||||
|                                 height: `${reactFlowBounds.height}px`, | ||||
|                                 transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`, | ||||
|                             }, | ||||
|                             quality: 1, | ||||
|                             pixelRatio: scale, | ||||
|                             skipFonts: true, | ||||
|                         } | ||||
|                     ); | ||||
|  | ||||
|                     // Create a canvas to combine the diagram and watermark | ||||
|                     const canvas = document.createElement('canvas'); | ||||
|                     const ctx = canvas.getContext('2d'); | ||||
|  | ||||
|                     if (!ctx) { | ||||
|                         downloadImage(initialDataUrl, type); | ||||
|                         return; | ||||
|                     } | ||||
|  | ||||
|                     // Set canvas size to match the export size | ||||
|                     canvas.width = reactFlowBounds.width * scale; | ||||
|                     canvas.height = reactFlowBounds.height * scale; | ||||
|  | ||||
|                     // Load the exported diagram | ||||
|                     const diagramImage = new Image(); | ||||
|                     diagramImage.src = initialDataUrl; | ||||
|  | ||||
|                     await new Promise((resolve) => { | ||||
|                         diagramImage.onload = async () => { | ||||
|                             // Draw the diagram | ||||
|                             ctx.drawImage(diagramImage, 0, 0); | ||||
|  | ||||
|                             // Calculate logo size | ||||
|                             const logoHeight = Math.max( | ||||
|                                 24, | ||||
|                                 Math.floor(canvas.width * 0.024) | ||||
|                             ); | ||||
|                             const padding = Math.max( | ||||
|                                 12, | ||||
|                                 Math.floor(logoHeight * 0.5) | ||||
|                             ); | ||||
|  | ||||
|                             // Load and draw the logo | ||||
|                             const logoImage = new Image(); | ||||
|                             logoImage.src = logoBase64; | ||||
|  | ||||
|                             await new Promise((resolve) => { | ||||
|                                 logoImage.onload = () => { | ||||
|                                     // Calculate logo width while maintaining aspect ratio | ||||
|                                     const logoWidth = | ||||
|                                         (logoImage.width / logoImage.height) * | ||||
|                                         logoHeight; | ||||
|  | ||||
|                                     // Draw logo in bottom-left corner | ||||
|                                     ctx.globalAlpha = 0.9; | ||||
|                                     ctx.drawImage( | ||||
|                                         logoImage, | ||||
|                                         padding, | ||||
|                                         canvas.height - logoHeight - padding, | ||||
|                                         logoWidth, | ||||
|                                         logoHeight | ||||
|                                     ); | ||||
|                                     ctx.globalAlpha = 1; | ||||
|                                     resolve(null); | ||||
|                                 }; | ||||
|                             }); | ||||
|  | ||||
|                             // Convert canvas to data URL | ||||
|                             const finalDataUrl = canvas.toDataURL( | ||||
|                                 type === 'png' ? 'image/png' : 'image/jpeg' | ||||
|                             ); | ||||
|                             downloadImage(finalDataUrl, type); | ||||
|                             resolve(null); | ||||
|                         }; | ||||
|                     }); | ||||
|                 } finally { | ||||
|                     viewportElement.removeChild(tempSvg); | ||||
|                     hideLoader(); | ||||
| @@ -183,6 +292,7 @@ export const ExportImageProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|             setNodes, | ||||
|             showLoader, | ||||
|             effectiveTheme, | ||||
|             logoBase64, | ||||
|         ] | ||||
|     ); | ||||
|  | ||||
|   | ||||
| @@ -9,6 +9,7 @@ import { useHistory } from '@/hooks/use-history'; | ||||
| import { useDialog } from '@/hooks/use-dialog'; | ||||
| import { useChartDB } from '@/hooks/use-chartdb'; | ||||
| import { useLayout } from '@/hooks/use-layout'; | ||||
| import { useReactFlow } from '@xyflow/react'; | ||||
|  | ||||
| export const KeyboardShortcutsProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|     children, | ||||
| @@ -17,6 +18,7 @@ export const KeyboardShortcutsProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|     const { openOpenDiagramDialog } = useDialog(); | ||||
|     const { updateDiagramUpdatedAt } = useChartDB(); | ||||
|     const { toggleSidePanel } = useLayout(); | ||||
|     const { fitView } = useReactFlow(); | ||||
|  | ||||
|     useHotkeys( | ||||
|         keyboardShortcutsForOS[KeyboardShortcutAction.REDO].keyCombination, | ||||
| @@ -37,7 +39,7 @@ export const KeyboardShortcutsProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|     useHotkeys( | ||||
|         keyboardShortcutsForOS[KeyboardShortcutAction.OPEN_DIAGRAM] | ||||
|             .keyCombination, | ||||
|         openOpenDiagramDialog, | ||||
|         () => openOpenDiagramDialog(), | ||||
|         { | ||||
|             preventDefault: true, | ||||
|         }, | ||||
| @@ -61,6 +63,20 @@ export const KeyboardShortcutsProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|         }, | ||||
|         [toggleSidePanel] | ||||
|     ); | ||||
|     useHotkeys( | ||||
|         keyboardShortcutsForOS[KeyboardShortcutAction.SHOW_ALL].keyCombination, | ||||
|         () => { | ||||
|             fitView({ | ||||
|                 duration: 500, | ||||
|                 padding: 0.1, | ||||
|                 maxZoom: 0.8, | ||||
|             }); | ||||
|         }, | ||||
|         { | ||||
|             preventDefault: true, | ||||
|         }, | ||||
|         [fitView] | ||||
|     ); | ||||
|  | ||||
|     return ( | ||||
|         <keyboardShortcutsContext.Provider value={{}}> | ||||
|   | ||||
| @@ -6,6 +6,8 @@ export enum KeyboardShortcutAction { | ||||
|     OPEN_DIAGRAM = 'open_diagram', | ||||
|     SAVE_DIAGRAM = 'save_diagram', | ||||
|     TOGGLE_SIDE_PANEL = 'toggle_side_panel', | ||||
|     SHOW_ALL = 'show_all', | ||||
|     TOGGLE_THEME = 'toggle_theme', | ||||
| } | ||||
|  | ||||
| export interface KeyboardShortcut { | ||||
| @@ -55,6 +57,20 @@ export const keyboardShortcuts: Record< | ||||
|         keyCombinationMac: 'meta+b', | ||||
|         keyCombinationWin: 'ctrl+b', | ||||
|     }, | ||||
|     [KeyboardShortcutAction.SHOW_ALL]: { | ||||
|         action: KeyboardShortcutAction.SHOW_ALL, | ||||
|         keyCombinationLabelMac: '⌘0', | ||||
|         keyCombinationLabelWin: 'Ctrl+0', | ||||
|         keyCombinationMac: 'meta+0', | ||||
|         keyCombinationWin: 'ctrl+0', | ||||
|     }, | ||||
|     [KeyboardShortcutAction.TOGGLE_THEME]: { | ||||
|         action: KeyboardShortcutAction.TOGGLE_THEME, | ||||
|         keyCombinationLabelMac: '⌘M', | ||||
|         keyCombinationLabelWin: 'Ctrl+M', | ||||
|         keyCombinationMac: 'meta+m', | ||||
|         keyCombinationWin: 'ctrl+m', | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| export interface KeyboardShortcutForOS { | ||||
|   | ||||
| @@ -32,6 +32,9 @@ export interface LocalConfigContext { | ||||
|  | ||||
|     showDependenciesOnCanvas: boolean; | ||||
|     setShowDependenciesOnCanvas: (showDependenciesOnCanvas: boolean) => void; | ||||
|  | ||||
|     showMiniMapOnCanvas: boolean; | ||||
|     setShowMiniMapOnCanvas: (showMiniMapOnCanvas: boolean) => void; | ||||
| } | ||||
|  | ||||
| export const LocalConfigContext = createContext<LocalConfigContext>({ | ||||
| @@ -44,7 +47,7 @@ export const LocalConfigContext = createContext<LocalConfigContext>({ | ||||
|     schemasFilter: {}, | ||||
|     setSchemasFilter: emptyFn, | ||||
|  | ||||
|     showCardinality: false, | ||||
|     showCardinality: true, | ||||
|     setShowCardinality: emptyFn, | ||||
|  | ||||
|     hideMultiSchemaNotification: false, | ||||
| @@ -58,4 +61,7 @@ export const LocalConfigContext = createContext<LocalConfigContext>({ | ||||
|  | ||||
|     showDependenciesOnCanvas: false, | ||||
|     setShowDependenciesOnCanvas: emptyFn, | ||||
|  | ||||
|     showMiniMapOnCanvas: false, | ||||
|     setShowMiniMapOnCanvas: emptyFn, | ||||
| }); | ||||
|   | ||||
| @@ -11,6 +11,7 @@ const hideMultiSchemaNotificationKey = 'hide_multi_schema_notification'; | ||||
| const githubRepoOpenedKey = 'github_repo_opened'; | ||||
| const starUsDialogLastOpenKey = 'star_us_dialog_last_open'; | ||||
| const showDependenciesOnCanvasKey = 'show_dependencies_on_canvas'; | ||||
| const showMiniMapOnCanvasKey = 'show_minimap_on_canvas'; | ||||
|  | ||||
| export const LocalConfigProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|     children, | ||||
| @@ -30,7 +31,7 @@ export const LocalConfigProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|     ); | ||||
|  | ||||
|     const [showCardinality, setShowCardinality] = React.useState<boolean>( | ||||
|         (localStorage.getItem(showCardinalityKey) || 'false') === 'true' | ||||
|         (localStorage.getItem(showCardinalityKey) || 'true') === 'true' | ||||
|     ); | ||||
|  | ||||
|     const [hideMultiSchemaNotification, setHideMultiSchemaNotification] = | ||||
| @@ -54,6 +55,11 @@ export const LocalConfigProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|                 'true' | ||||
|         ); | ||||
|  | ||||
|     const [showMiniMapOnCanvas, setShowMiniMapOnCanvas] = | ||||
|         React.useState<boolean>( | ||||
|             (localStorage.getItem(showMiniMapOnCanvasKey) || 'true') === 'true' | ||||
|         ); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         localStorage.setItem( | ||||
|             starUsDialogLastOpenKey, | ||||
| @@ -95,6 +101,13 @@ export const LocalConfigProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|         ); | ||||
|     }, [showDependenciesOnCanvas]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         localStorage.setItem( | ||||
|             showMiniMapOnCanvasKey, | ||||
|             showMiniMapOnCanvas.toString() | ||||
|         ); | ||||
|     }, [showMiniMapOnCanvas]); | ||||
|  | ||||
|     return ( | ||||
|         <LocalConfigContext.Provider | ||||
|             value={{ | ||||
| @@ -114,6 +127,8 @@ export const LocalConfigProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|                 setStarUsDialogLastOpen, | ||||
|                 showDependenciesOnCanvas, | ||||
|                 setShowDependenciesOnCanvas, | ||||
|                 showMiniMapOnCanvas, | ||||
|                 setShowMiniMapOnCanvas, | ||||
|             }} | ||||
|         > | ||||
|             {children} | ||||
|   | ||||
| @@ -122,6 +122,32 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|         config: '++id, defaultDiagramId', | ||||
|     }); | ||||
|  | ||||
|     db.version(8).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', | ||||
|         config: '++id, defaultDiagramId', | ||||
|     }); | ||||
|  | ||||
|     db.version(9).upgrade((tx) => | ||||
|         tx | ||||
|             .table<DBTable & { diagramId: string }>('db_tables') | ||||
|             .toCollection() | ||||
|             .modify((table) => { | ||||
|                 for (const field of table.fields) { | ||||
|                     if (typeof field.nullable === 'string') { | ||||
|                         field.nullable = | ||||
|                             (field.nullable as string).toLowerCase() === 'true'; | ||||
|                     } | ||||
|                 } | ||||
|             }) | ||||
|     ); | ||||
|  | ||||
|     db.on('ready', async () => { | ||||
|         const config = await getConfig(); | ||||
|  | ||||
| @@ -270,6 +296,23 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|         attributes: Partial<Diagram>; | ||||
|     }) => { | ||||
|         await db.diagrams.update(id, attributes); | ||||
|  | ||||
|         if (attributes.id) { | ||||
|             await Promise.all([ | ||||
|                 db.db_tables | ||||
|                     .where('diagramId') | ||||
|                     .equals(id) | ||||
|                     .modify({ diagramId: attributes.id }), | ||||
|                 db.db_relationships | ||||
|                     .where('diagramId') | ||||
|                     .equals(id) | ||||
|                     .modify({ diagramId: attributes.id }), | ||||
|                 db.db_dependencies | ||||
|                     .where('diagramId') | ||||
|                     .equals(id) | ||||
|                     .modify({ diagramId: attributes.id }), | ||||
|             ]); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const deleteDiagram: StorageContext['deleteDiagram'] = async ( | ||||
| @@ -345,15 +388,7 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|             .equals(diagramId) | ||||
|             .toArray(); | ||||
|  | ||||
|         // Sort tables first alphabetically, then views alphabetically | ||||
|         return tables.sort((a, b) => { | ||||
|             if (a.isView === b.isView) { | ||||
|                 // Both are either tables or views, so sort alphabetically by name | ||||
|                 return a.name.localeCompare(b.name); | ||||
|             } | ||||
|             // If one is a view and the other is not, put tables first | ||||
|             return a.isView ? 1 : -1; | ||||
|         }); | ||||
|         return tables; | ||||
|     }; | ||||
|  | ||||
|     const addRelationship: StorageContext['addRelationship'] = async ({ | ||||
|   | ||||
| @@ -1,8 +1,13 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import React, { useEffect, useState, useCallback } from 'react'; | ||||
| import type { EffectiveTheme } from './theme-context'; | ||||
| import { ThemeContext } from './theme-context'; | ||||
| import { useMediaQuery } from 'react-responsive'; | ||||
| import { useLocalConfig } from '@/hooks/use-local-config'; | ||||
| import { useHotkeys } from 'react-hotkeys-hook'; | ||||
| import { | ||||
|     KeyboardShortcutAction, | ||||
|     keyboardShortcutsForOS, | ||||
| } from '../keyboard-shortcuts-context/keyboard-shortcuts'; | ||||
|  | ||||
| export const ThemeProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|     children, | ||||
| @@ -29,6 +34,24 @@ export const ThemeProvider: React.FC<React.PropsWithChildren> = ({ | ||||
|         } | ||||
|     }, [effectiveTheme]); | ||||
|  | ||||
|     const handleThemeToggle = useCallback(() => { | ||||
|         if (theme === 'system') { | ||||
|             setTheme(effectiveTheme === 'dark' ? 'light' : 'dark'); | ||||
|         } else { | ||||
|             setTheme(theme === 'dark' ? 'light' : 'dark'); | ||||
|         } | ||||
|     }, [theme, effectiveTheme, setTheme]); | ||||
|  | ||||
|     useHotkeys( | ||||
|         keyboardShortcutsForOS[KeyboardShortcutAction.TOGGLE_THEME] | ||||
|             .keyCombination, | ||||
|         handleThemeToggle, | ||||
|         { | ||||
|             preventDefault: true, | ||||
|         }, | ||||
|         [handleThemeToggle] | ||||
|     ); | ||||
|  | ||||
|     return ( | ||||
|         <ThemeContext.Provider value={{ theme, setTheme, effectiveTheme }}> | ||||
|             {children} | ||||
|   | ||||
| @@ -10,7 +10,7 @@ import { | ||||
|     AlertDialogTitle, | ||||
| } from '@/components/alert-dialog/alert-dialog'; | ||||
| import type { AlertDialogProps } from '@radix-ui/react-alert-dialog'; | ||||
| import { useDialog } from '@/hooks/use-dialog'; | ||||
| import { useAlert } from '@/context/alert-context/alert-context'; | ||||
|  | ||||
| export interface BaseAlertDialogProps { | ||||
|     title: string; | ||||
| @@ -33,7 +33,7 @@ export const BaseAlertDialog: React.FC<BaseAlertDialogProps> = ({ | ||||
|     content, | ||||
|     onClose, | ||||
| }) => { | ||||
|     const { closeAlert } = useDialog(); | ||||
|     const { closeAlert } = useAlert(); | ||||
|  | ||||
|     const closeAlertHandler = useCallback(() => { | ||||
|         onClose?.(); | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import React, { useCallback, useEffect, useState } from 'react'; | ||||
| import React, { Suspense, useCallback, useEffect, useState } from 'react'; | ||||
| import { Button } from '@/components/button/button'; | ||||
| import { | ||||
|     DialogClose, | ||||
| @@ -8,31 +8,10 @@ import { | ||||
|     DialogInternalContent, | ||||
|     DialogTitle, | ||||
| } from '@/components/dialog/dialog'; | ||||
| import { ToggleGroup, ToggleGroupItem } from '@/components/toggle/toggle-group'; | ||||
| import { DatabaseType } from '@/lib/domain/database-type'; | ||||
| import { databaseSecondaryLogoMap } from '@/lib/databases'; | ||||
| import { CodeSnippet } from '@/components/code-snippet/code-snippet'; | ||||
| import { Textarea } from '@/components/textarea/textarea'; | ||||
| import type { DatabaseType } from '@/lib/domain/database-type'; | ||||
| import { Editor } from '@/components/code-snippet/code-snippet'; | ||||
| import type { DatabaseEdition } from '@/lib/domain/database-edition'; | ||||
| import { | ||||
|     databaseEditionToImageMap, | ||||
|     databaseEditionToLabelMap, | ||||
|     databaseTypeToEditionMap, | ||||
| } from '@/lib/domain/database-edition'; | ||||
| import { | ||||
|     Avatar, | ||||
|     AvatarFallback, | ||||
|     AvatarImage, | ||||
| } from '@/components/avatar/avatar'; | ||||
| import { SSMSInfo } from './ssms-info/ssms-info'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { Tabs, TabsList, TabsTrigger } from '@/components/tabs/tabs'; | ||||
| import type { DatabaseClient } from '@/lib/domain/database-clients'; | ||||
| import { | ||||
|     databaseClientToLabelMap, | ||||
|     databaseTypeToClientsMap, | ||||
| } from '@/lib/domain/database-clients'; | ||||
| import type { ImportMetadataScripts } from '@/lib/data/import-metadata/scripts/scripts'; | ||||
| import { ZoomableImage } from '@/components/zoomable-image/zoomable-image'; | ||||
| import { useBreakpoint } from '@/hooks/use-breakpoint'; | ||||
| import { Spinner } from '@/components/spinner/spinner'; | ||||
| @@ -40,6 +19,17 @@ import { | ||||
|     fixMetadataJson, | ||||
|     isStringMetadataJson, | ||||
| } from '@/lib/data/import-metadata/utils'; | ||||
| import { | ||||
|     ResizableHandle, | ||||
|     ResizablePanel, | ||||
|     ResizablePanelGroup, | ||||
| } from '@/components/resizable/resizable'; | ||||
| import { useTheme } from '@/hooks/use-theme'; | ||||
| import type { OnChange } from '@monaco-editor/react'; | ||||
| import { useDebounce } from '@/hooks/use-debounce-v2'; | ||||
| import { InstructionsSection } from './instructions-section/instructions-section'; | ||||
| import { parseSQLError } from '@/lib/data/sql-import'; | ||||
| import type { editor } from 'monaco-editor'; | ||||
|  | ||||
| const errorScriptOutputMessage = | ||||
|     'Invalid JSON. Please correct it or contact us at chartdb.io@gmail.com for help.'; | ||||
| @@ -57,6 +47,8 @@ export interface ImportDatabaseProps { | ||||
|     >; | ||||
|     keepDialogAfterImport?: boolean; | ||||
|     title: string; | ||||
|     importMethod: 'query' | 'ddl'; | ||||
|     setImportMethod: (method: 'query' | 'ddl') => void; | ||||
| } | ||||
|  | ||||
| export const ImportDatabase: React.FC<ImportDatabaseProps> = ({ | ||||
| @@ -70,32 +62,51 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({ | ||||
|     setDatabaseEdition, | ||||
|     keepDialogAfterImport, | ||||
|     title, | ||||
|     importMethod, | ||||
|     setImportMethod, | ||||
| }) => { | ||||
|     const databaseClients = databaseTypeToClientsMap[databaseType]; | ||||
|     const { effectiveTheme } = useTheme(); | ||||
|     const [errorMessage, setErrorMessage] = useState(''); | ||||
|     const [databaseClient, setDatabaseClient] = useState< | ||||
|         DatabaseClient | undefined | ||||
|     >(); | ||||
|     const { t } = useTranslation(); | ||||
|     const [importMetadataScripts, setImportMetadataScripts] = | ||||
|         useState<ImportMetadataScripts | null>(null); | ||||
|  | ||||
|     const { t } = useTranslation(); | ||||
|     const { isSm: isDesktop } = useBreakpoint('sm'); | ||||
|  | ||||
|     const [showCheckJsonButton, setShowCheckJsonButton] = useState(false); | ||||
|     const [isCheckingJson, setIsCheckingJson] = useState(false); | ||||
|     const [showSSMSInfoDialog, setShowSSMSInfoDialog] = useState(false); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         const loadScripts = async () => { | ||||
|             const { importMetadataScripts } = await import( | ||||
|                 '@/lib/data/import-metadata/scripts/scripts' | ||||
|             ); | ||||
|             setImportMetadataScripts(importMetadataScripts); | ||||
|         }; | ||||
|         loadScripts(); | ||||
|     }, []); | ||||
|         setScriptResult(''); | ||||
|         setErrorMessage(''); | ||||
|         setShowCheckJsonButton(false); | ||||
|     }, [importMethod, setScriptResult]); | ||||
|  | ||||
|     // Check if the ddl is valid | ||||
|     useEffect(() => { | ||||
|         if (importMethod !== 'ddl') { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (!scriptResult.trim()) return; | ||||
|  | ||||
|         parseSQLError({ | ||||
|             sqlContent: scriptResult, | ||||
|             sourceDatabaseType: databaseType, | ||||
|         }).then((result) => { | ||||
|             if (result.success) { | ||||
|                 setErrorMessage(''); | ||||
|             } else if (!result.success && result.error) { | ||||
|                 setErrorMessage(result.error); | ||||
|             } | ||||
|         }); | ||||
|     }, [importMethod, scriptResult, databaseType]); | ||||
|  | ||||
|     // Check if the script result is a valid JSON | ||||
|     useEffect(() => { | ||||
|         if (importMethod !== 'query') { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (scriptResult.trim().length === 0) { | ||||
|             setErrorMessage(''); | ||||
|             setShowCheckJsonButton(false); | ||||
| @@ -115,7 +126,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({ | ||||
|             setErrorMessage(errorScriptOutputMessage); | ||||
|             setShowCheckJsonButton(false); | ||||
|         } | ||||
|     }, [scriptResult]); | ||||
|     }, [scriptResult, importMethod]); | ||||
|  | ||||
|     const handleImport = useCallback(() => { | ||||
|         if (errorMessage.length === 0 && scriptResult.trim().length !== 0) { | ||||
| @@ -123,14 +134,20 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({ | ||||
|         } | ||||
|     }, [errorMessage.length, onImport, scriptResult]); | ||||
|  | ||||
|     const handleInputChange = useCallback( | ||||
|         (e: React.ChangeEvent<HTMLTextAreaElement>) => { | ||||
|             const inputValue = e.target.value; | ||||
|             setScriptResult(inputValue); | ||||
|     const handleInputChange: OnChange = useCallback( | ||||
|         (inputValue) => { | ||||
|             setScriptResult(inputValue ?? ''); | ||||
|  | ||||
|             // Automatically open SSMS info when input length is exactly 65535 | ||||
|             if ((inputValue ?? '').length === 65535) { | ||||
|                 setShowSSMSInfoDialog(true); | ||||
|             } | ||||
|         }, | ||||
|         [setScriptResult] | ||||
|     ); | ||||
|  | ||||
|     const debouncedHandleInputChange = useDebounce(handleInputChange, 500); | ||||
|  | ||||
|     const handleCheckJson = useCallback(async () => { | ||||
|         setIsCheckingJson(true); | ||||
|  | ||||
| @@ -148,6 +165,17 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({ | ||||
|         setIsCheckingJson(false); | ||||
|     }, [scriptResult, setScriptResult]); | ||||
|  | ||||
|     const handleEditorDidMount = useCallback( | ||||
|         (editor: editor.IStandaloneCodeEditor) => { | ||||
|             editor.onDidPaste(() => { | ||||
|                 setTimeout(() => { | ||||
|                     editor.getAction('editor.action.formatDocument')?.run(); | ||||
|                 }, 0); | ||||
|             }); | ||||
|         }, | ||||
|         [] | ||||
|     ); | ||||
|  | ||||
|     const renderHeader = useCallback(() => { | ||||
|         return ( | ||||
|             <DialogHeader> | ||||
| @@ -157,223 +185,156 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({ | ||||
|         ); | ||||
|     }, [title]); | ||||
|  | ||||
|     const renderInstructions = useCallback( | ||||
|         () => ( | ||||
|             <InstructionsSection | ||||
|                 databaseType={databaseType} | ||||
|                 importMethod={importMethod} | ||||
|                 setDatabaseEdition={setDatabaseEdition} | ||||
|                 setImportMethod={setImportMethod} | ||||
|                 databaseEdition={databaseEdition} | ||||
|                 setShowSSMSInfoDialog={setShowSSMSInfoDialog} | ||||
|                 showSSMSInfoDialog={showSSMSInfoDialog} | ||||
|             /> | ||||
|         ), | ||||
|         [ | ||||
|             databaseType, | ||||
|             importMethod, | ||||
|             setDatabaseEdition, | ||||
|             setImportMethod, | ||||
|             databaseEdition, | ||||
|             setShowSSMSInfoDialog, | ||||
|             showSSMSInfoDialog, | ||||
|         ] | ||||
|     ); | ||||
|  | ||||
|     const renderOutputTextArea = useCallback( | ||||
|         () => ( | ||||
|             <div className="flex size-full flex-col gap-1 overflow-hidden rounded-md border p-1"> | ||||
|                 <div className="w-full text-center text-xs text-muted-foreground"> | ||||
|                     {importMethod === 'query' | ||||
|                         ? 'Smart Query Output' | ||||
|                         : 'SQL DDL'} | ||||
|                 </div> | ||||
|                 <div className="flex-1 overflow-hidden"> | ||||
|                     <Suspense fallback={<Spinner />}> | ||||
|                         <Editor | ||||
|                             value={scriptResult} | ||||
|                             onChange={debouncedHandleInputChange} | ||||
|                             language={importMethod === 'query' ? 'json' : 'sql'} | ||||
|                             loading={<Spinner />} | ||||
|                             onMount={handleEditorDidMount} | ||||
|                             theme={ | ||||
|                                 effectiveTheme === 'dark' | ||||
|                                     ? 'dbml-dark' | ||||
|                                     : 'dbml-light' | ||||
|                             } | ||||
|                             options={{ | ||||
|                                 formatOnPaste: true, | ||||
|                                 minimap: { enabled: false }, | ||||
|                                 scrollBeyondLastLine: false, | ||||
|                                 automaticLayout: true, | ||||
|                                 glyphMargin: false, | ||||
|                                 lineNumbers: 'on', | ||||
|                                 guides: { | ||||
|                                     indentation: false, | ||||
|                                 }, | ||||
|                                 folding: true, | ||||
|                                 lineNumbersMinChars: 3, | ||||
|                                 renderValidationDecorations: 'off', | ||||
|                                 lineDecorationsWidth: 0, | ||||
|                                 overviewRulerBorder: false, | ||||
|                                 overviewRulerLanes: 0, | ||||
|                                 hideCursorInOverviewRuler: true, | ||||
|                                 contextmenu: false, | ||||
|  | ||||
|                                 scrollbar: { | ||||
|                                     vertical: 'hidden', | ||||
|                                     horizontal: 'hidden', | ||||
|                                     alwaysConsumeMouseWheel: false, | ||||
|                                 }, | ||||
|                             }} | ||||
|                             className="size-full min-h-40" | ||||
|                         /> | ||||
|                     </Suspense> | ||||
|                 </div> | ||||
|  | ||||
|                 {showCheckJsonButton || errorMessage ? ( | ||||
|                     <div className="mt-2 flex shrink-0 items-center gap-2"> | ||||
|                         {showCheckJsonButton ? ( | ||||
|                             <Button | ||||
|                                 type="button" | ||||
|                                 variant="outline" | ||||
|                                 size="sm" | ||||
|                                 onClick={handleCheckJson} | ||||
|                                 disabled={isCheckingJson} | ||||
|                                 className="h-7" | ||||
|                             > | ||||
|                                 {isCheckingJson ? ( | ||||
|                                     <Spinner size="small" /> | ||||
|                                 ) : ( | ||||
|                                     t( | ||||
|                                         'new_diagram_dialog.import_database.check_script_result' | ||||
|                                     ) | ||||
|                                 )} | ||||
|                             </Button> | ||||
|                         ) : ( | ||||
|                             <p className="text-xs text-red-700"> | ||||
|                                 {errorMessage} | ||||
|                             </p> | ||||
|                         )} | ||||
|                     </div> | ||||
|                 ) : null} | ||||
|             </div> | ||||
|         ), | ||||
|         [ | ||||
|             errorMessage, | ||||
|             scriptResult, | ||||
|             importMethod, | ||||
|             effectiveTheme, | ||||
|             debouncedHandleInputChange, | ||||
|             handleEditorDidMount, | ||||
|             showCheckJsonButton, | ||||
|             isCheckingJson, | ||||
|             handleCheckJson, | ||||
|             t, | ||||
|         ] | ||||
|     ); | ||||
|  | ||||
|     const renderContent = useCallback(() => { | ||||
|         return ( | ||||
|             <DialogInternalContent> | ||||
|                 <div className="flex w-full flex-1 flex-col gap-6"> | ||||
|                     {databaseTypeToEditionMap[databaseType].length > 0 ? ( | ||||
|                         <div className="flex flex-col gap-1 md:flex-row"> | ||||
|                             <p className="text-sm leading-6 text-muted-foreground"> | ||||
|                                 {t( | ||||
|                                     'new_diagram_dialog.import_database.database_edition' | ||||
|                                 )} | ||||
|                             </p> | ||||
|                             <ToggleGroup | ||||
|                                 type="single" | ||||
|                                 className="ml-1 flex-wrap gap-2" | ||||
|                                 value={ | ||||
|                                     !databaseEdition | ||||
|                                         ? 'regular' | ||||
|                                         : databaseEdition | ||||
|                                 } | ||||
|                                 onValueChange={(value) => { | ||||
|                                     setDatabaseEdition( | ||||
|                                         value === 'regular' | ||||
|                                             ? undefined | ||||
|                                             : (value as DatabaseEdition) | ||||
|                                     ); | ||||
|                                 }} | ||||
|                             > | ||||
|                                 <ToggleGroupItem | ||||
|                                     value="regular" | ||||
|                                     variant="outline" | ||||
|                                     className="h-6 gap-1 p-0 px-2 shadow-none" | ||||
|                                 > | ||||
|                                     <Avatar className="size-4 rounded-none"> | ||||
|                                         <AvatarImage | ||||
|                                             src={ | ||||
|                                                 databaseSecondaryLogoMap[ | ||||
|                                                     databaseType | ||||
|                                                 ] | ||||
|                                             } | ||||
|                                             alt="Regular" | ||||
|                                         /> | ||||
|                                         <AvatarFallback>Regular</AvatarFallback> | ||||
|                                     </Avatar> | ||||
|                                     Regular | ||||
|                                 </ToggleGroupItem> | ||||
|                                 {databaseTypeToEditionMap[databaseType].map( | ||||
|                                     (edition) => ( | ||||
|                                         <ToggleGroupItem | ||||
|                                             value={edition} | ||||
|                                             key={edition} | ||||
|                                             variant="outline" | ||||
|                                             className="h-6 gap-1 p-0 px-2 shadow-none" | ||||
|                                         > | ||||
|                                             <Avatar className="size-4"> | ||||
|                                                 <AvatarImage | ||||
|                                                     src={ | ||||
|                                                         databaseEditionToImageMap[ | ||||
|                                                             edition | ||||
|                                                         ] | ||||
|                                                     } | ||||
|                                                     alt={ | ||||
|                                                         databaseEditionToLabelMap[ | ||||
|                                                             edition | ||||
|                                                         ] | ||||
|                                                     } | ||||
|                                                 /> | ||||
|                                                 <AvatarFallback> | ||||
|                                                     { | ||||
|                                                         databaseEditionToLabelMap[ | ||||
|                                                             edition | ||||
|                                                         ] | ||||
|                                                     } | ||||
|                                                 </AvatarFallback> | ||||
|                                             </Avatar> | ||||
|                                             {databaseEditionToLabelMap[edition]} | ||||
|                                         </ToggleGroupItem> | ||||
|                                     ) | ||||
|                                 )} | ||||
|                             </ToggleGroup> | ||||
|                         </div> | ||||
|                     ) : null} | ||||
|                     <div className="flex flex-col gap-1"> | ||||
|                         <div className="flex flex-col gap-1 text-sm text-muted-foreground md:flex-row md:justify-between"> | ||||
|                             <div> | ||||
|                                 1.{' '} | ||||
|                                 {t('new_diagram_dialog.import_database.step_1')} | ||||
|                             </div> | ||||
|                             {databaseType === DatabaseType.SQL_SERVER && ( | ||||
|                                 <SSMSInfo /> | ||||
|                             )} | ||||
|                         </div> | ||||
|                         {databaseTypeToClientsMap[databaseType].length > 0 ? ( | ||||
|                             <Tabs | ||||
|                                 value={ | ||||
|                                     !databaseClient | ||||
|                                         ? 'dbclient' | ||||
|                                         : databaseClient | ||||
|                                 } | ||||
|                                 onValueChange={(value) => { | ||||
|                                     setDatabaseClient( | ||||
|                                         value === 'dbclient' | ||||
|                                             ? undefined | ||||
|                                             : (value as DatabaseClient) | ||||
|                                     ); | ||||
|                                 }} | ||||
|                             > | ||||
|                                 <div className="flex flex-1"> | ||||
|                                     <TabsList className="h-8 justify-start rounded-none rounded-t-sm "> | ||||
|                                         <TabsTrigger | ||||
|                                             value="dbclient" | ||||
|                                             className="h-6 w-20" | ||||
|                                         > | ||||
|                                             DB Client | ||||
|                                         </TabsTrigger> | ||||
|  | ||||
|                                         {databaseClients?.map((client) => ( | ||||
|                                             <TabsTrigger | ||||
|                                                 key={client} | ||||
|                                                 value={client} | ||||
|                                                 className="h-6 !w-20" | ||||
|                                             > | ||||
|                                                 { | ||||
|                                                     databaseClientToLabelMap[ | ||||
|                                                         client | ||||
|                                                     ] | ||||
|                                                 } | ||||
|                                             </TabsTrigger> | ||||
|                                         )) ?? []} | ||||
|                                     </TabsList> | ||||
|                                 </div> | ||||
|                                 <CodeSnippet | ||||
|                                     className="h-40 w-full" | ||||
|                                     loading={!importMetadataScripts} | ||||
|                                     code={ | ||||
|                                         importMetadataScripts?.[databaseType]?.( | ||||
|                                             { | ||||
|                                                 databaseEdition, | ||||
|                                                 databaseClient, | ||||
|                                             } | ||||
|                                         ) ?? '' | ||||
|                                     } | ||||
|                                     language={databaseClient ? 'shell' : 'sql'} | ||||
|                                 /> | ||||
|                             </Tabs> | ||||
|                         ) : ( | ||||
|                             <CodeSnippet | ||||
|                                 className="h-40 w-full flex-auto" | ||||
|                                 loading={!importMetadataScripts} | ||||
|                                 code={ | ||||
|                                     importMetadataScripts?.[databaseType]?.({ | ||||
|                                         databaseEdition, | ||||
|                                     }) ?? '' | ||||
|                                 } | ||||
|                                 language="sql" | ||||
|                             /> | ||||
|                         )} | ||||
|                 {isDesktop ? ( | ||||
|                     <ResizablePanelGroup | ||||
|                         direction={isDesktop ? 'horizontal' : 'vertical'} | ||||
|                         className="min-h-[500px] md:min-h-fit" | ||||
|                     > | ||||
|                         <ResizablePanel | ||||
|                             defaultSize={25} | ||||
|                             minSize={25} | ||||
|                             maxSize={99} | ||||
|                             className="min-h-fit rounded-md bg-gradient-to-b from-slate-50 to-slate-100 p-2 dark:from-slate-900 dark:to-slate-800 md:min-h-fit md:min-w-[350px] md:rounded-l-md md:p-2" | ||||
|                         > | ||||
|                             {renderInstructions()} | ||||
|                         </ResizablePanel> | ||||
|                         <ResizableHandle withHandle /> | ||||
|                         <ResizablePanel className="min-h-40 py-2 md:px-2 md:py-0"> | ||||
|                             {renderOutputTextArea()} | ||||
|                         </ResizablePanel> | ||||
|                     </ResizablePanelGroup> | ||||
|                 ) : ( | ||||
|                     <div className="flex flex-col gap-2"> | ||||
|                         {renderInstructions()} | ||||
|                         {renderOutputTextArea()} | ||||
|                     </div> | ||||
|                     <div className="flex h-48 flex-col gap-1"> | ||||
|                         <p className="text-sm text-muted-foreground"> | ||||
|                             2. {t('new_diagram_dialog.import_database.step_2')} | ||||
|                         </p> | ||||
|                         <Textarea | ||||
|                             className="w-full flex-1 rounded-md bg-muted p-2 text-sm" | ||||
|                             placeholder={t( | ||||
|                                 'new_diagram_dialog.import_database.script_results_placeholder' | ||||
|                             )} | ||||
|                             value={scriptResult} | ||||
|                             onChange={handleInputChange} | ||||
|                         /> | ||||
|                         {showCheckJsonButton || errorMessage ? ( | ||||
|                             <div className="mt-2 flex items-center gap-2"> | ||||
|                                 {showCheckJsonButton ? ( | ||||
|                                     <Button | ||||
|                                         type="button" | ||||
|                                         variant="outline" | ||||
|                                         size="sm" | ||||
|                                         onClick={handleCheckJson} | ||||
|                                         disabled={isCheckingJson} | ||||
|                                     > | ||||
|                                         {isCheckingJson ? ( | ||||
|                                             <Spinner size="small" /> | ||||
|                                         ) : ( | ||||
|                                             t( | ||||
|                                                 'new_diagram_dialog.import_database.check_script_result' | ||||
|                                             ) | ||||
|                                         )} | ||||
|                                     </Button> | ||||
|                                 ) : ( | ||||
|                                     <p className="text-sm text-red-700"> | ||||
|                                         {errorMessage} | ||||
|                                     </p> | ||||
|                                 )} | ||||
|                             </div> | ||||
|                         ) : null} | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 )} | ||||
|             </DialogInternalContent> | ||||
|         ); | ||||
|     }, [ | ||||
|         databaseEdition, | ||||
|         databaseType, | ||||
|         errorMessage, | ||||
|         handleInputChange, | ||||
|         scriptResult, | ||||
|         setDatabaseEdition, | ||||
|         databaseClients, | ||||
|         databaseClient, | ||||
|         importMetadataScripts, | ||||
|         t, | ||||
|         showCheckJsonButton, | ||||
|         isCheckingJson, | ||||
|         handleCheckJson, | ||||
|     ]); | ||||
|     }, [renderOutputTextArea, renderInstructions, isDesktop]); | ||||
|  | ||||
|     const renderFooter = useCallback(() => { | ||||
|         return ( | ||||
|             <DialogFooter className="mt-4 flex !justify-between gap-2"> | ||||
|             <DialogFooter className="flex !justify-between gap-2"> | ||||
|                 <div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2"> | ||||
|                     {goBack && ( | ||||
|                         <Button | ||||
|   | ||||
| @@ -0,0 +1,178 @@ | ||||
| import React from 'react'; | ||||
| import logo from '@/assets/logo-2.png'; | ||||
| import { ToggleGroup, ToggleGroupItem } from '@/components/toggle/toggle-group'; | ||||
| import { DatabaseType } from '@/lib/domain/database-type'; | ||||
| import { databaseSecondaryLogoMap } from '@/lib/databases'; | ||||
| import type { DatabaseEdition } from '@/lib/domain/database-edition'; | ||||
| import { | ||||
|     databaseEditionToImageMap, | ||||
|     databaseEditionToLabelMap, | ||||
|     databaseTypeToEditionMap, | ||||
| } from '@/lib/domain/database-edition'; | ||||
| import { | ||||
|     Avatar, | ||||
|     AvatarFallback, | ||||
|     AvatarImage, | ||||
| } from '@/components/avatar/avatar'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { Code } from 'lucide-react'; | ||||
| import { SmartQueryInstructions } from './instructions/smart-query-instructions'; | ||||
| import { DDLInstructions } from './instructions/ddl-instructions'; | ||||
|  | ||||
| const DatabasesWithoutDDLInstructions: DatabaseType[] = [ | ||||
|     DatabaseType.CLICKHOUSE, | ||||
| ]; | ||||
|  | ||||
| export interface InstructionsSectionProps { | ||||
|     databaseType: DatabaseType; | ||||
|     databaseEdition?: DatabaseEdition; | ||||
|     setDatabaseEdition: React.Dispatch< | ||||
|         React.SetStateAction<DatabaseEdition | undefined> | ||||
|     >; | ||||
|     importMethod: 'query' | 'ddl'; | ||||
|     setImportMethod: (method: 'query' | 'ddl') => void; | ||||
|     showSSMSInfoDialog: boolean; | ||||
|     setShowSSMSInfoDialog: (show: boolean) => void; | ||||
| } | ||||
|  | ||||
| export const InstructionsSection: React.FC<InstructionsSectionProps> = ({ | ||||
|     databaseType, | ||||
|     databaseEdition, | ||||
|     setDatabaseEdition, | ||||
|     importMethod, | ||||
|     setImportMethod, | ||||
|     setShowSSMSInfoDialog, | ||||
|     showSSMSInfoDialog, | ||||
| }) => { | ||||
|     const { t } = useTranslation(); | ||||
|  | ||||
|     return ( | ||||
|         <div className="flex w-full flex-1 flex-col gap-4"> | ||||
|             {databaseTypeToEditionMap[databaseType].length > 0 ? ( | ||||
|                 <div className="flex flex-col gap-1"> | ||||
|                     <p className="text-sm leading-6 text-primary"> | ||||
|                         {t( | ||||
|                             'new_diagram_dialog.import_database.database_edition' | ||||
|                         )} | ||||
|                     </p> | ||||
|                     <ToggleGroup | ||||
|                         type="single" | ||||
|                         className="ml-1 flex-wrap justify-start gap-2" | ||||
|                         value={!databaseEdition ? 'regular' : databaseEdition} | ||||
|                         onValueChange={(value) => { | ||||
|                             setDatabaseEdition( | ||||
|                                 value === 'regular' | ||||
|                                     ? undefined | ||||
|                                     : (value as DatabaseEdition) | ||||
|                             ); | ||||
|                         }} | ||||
|                     > | ||||
|                         <ToggleGroupItem | ||||
|                             value="regular" | ||||
|                             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"> | ||||
|                                 <AvatarImage | ||||
|                                     src={databaseSecondaryLogoMap[databaseType]} | ||||
|                                     alt="Regular" | ||||
|                                 /> | ||||
|                                 <AvatarFallback>Regular</AvatarFallback> | ||||
|                             </Avatar> | ||||
|                             Regular | ||||
|                         </ToggleGroupItem> | ||||
|                         {databaseTypeToEditionMap[databaseType].map( | ||||
|                             (edition) => ( | ||||
|                                 <ToggleGroupItem | ||||
|                                     value={edition} | ||||
|                                     key={edition} | ||||
|                                     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"> | ||||
|                                         <AvatarImage | ||||
|                                             src={ | ||||
|                                                 databaseEditionToImageMap[ | ||||
|                                                     edition | ||||
|                                                 ] | ||||
|                                             } | ||||
|                                             alt={ | ||||
|                                                 databaseEditionToLabelMap[ | ||||
|                                                     edition | ||||
|                                                 ] | ||||
|                                             } | ||||
|                                         /> | ||||
|                                         <AvatarFallback> | ||||
|                                             {databaseEditionToLabelMap[edition]} | ||||
|                                         </AvatarFallback> | ||||
|                                     </Avatar> | ||||
|                                     {databaseEditionToLabelMap[edition]} | ||||
|                                 </ToggleGroupItem> | ||||
|                             ) | ||||
|                         )} | ||||
|                     </ToggleGroup> | ||||
|                 </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: 'query' | 'ddl' = 'query'; | ||||
|                             if (value) { | ||||
|                                 selectedImportMethod = value as 'query' | 'ddl'; | ||||
|                             } | ||||
|  | ||||
|                             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" | ||||
|                         > | ||||
|                             <Avatar className="h-3 w-4 rounded-none"> | ||||
|                                 <AvatarImage src={logo} alt="query" /> | ||||
|                                 <AvatarFallback>Query</AvatarFallback> | ||||
|                             </Avatar> | ||||
|                             Smart Query | ||||
|                         </ToggleGroupItem> | ||||
|                         <ToggleGroupItem | ||||
|                             value="ddl" | ||||
|                             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> | ||||
|                             DDL | ||||
|                         </ToggleGroupItem> | ||||
|                     </ToggleGroup> | ||||
|                 </div> | ||||
|             )} | ||||
|  | ||||
|             <div className="flex flex-col gap-2"> | ||||
|                 <div className="text-sm font-semibold">Instructions:</div> | ||||
|                 {importMethod === 'query' ? ( | ||||
|                     <SmartQueryInstructions | ||||
|                         databaseType={databaseType} | ||||
|                         databaseEdition={databaseEdition} | ||||
|                         showSSMSInfoDialog={showSSMSInfoDialog} | ||||
|                         setShowSSMSInfoDialog={setShowSSMSInfoDialog} | ||||
|                     /> | ||||
|                 ) : ( | ||||
|                     <DDLInstructions | ||||
|                         databaseType={databaseType} | ||||
|                         databaseEdition={databaseEdition} | ||||
|                     /> | ||||
|                 )} | ||||
|             </div> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -0,0 +1,48 @@ | ||||
| import React from 'react'; | ||||
| import { CodeSnippet } from '@/components/code-snippet/code-snippet'; | ||||
|  | ||||
| export interface DDLInstructionStepProps { | ||||
|     index: number; | ||||
|     text: string; | ||||
|     code?: string; | ||||
|     example?: string; | ||||
| } | ||||
|  | ||||
| export const DDLInstructionStep: React.FC<DDLInstructionStepProps> = ({ | ||||
|     index, | ||||
|     text, | ||||
|     code, | ||||
|     example, | ||||
| }) => { | ||||
|     return ( | ||||
|         <div className="flex flex-col gap-1"> | ||||
|             <div className="flex flex-col gap-1 text-sm text-primary"> | ||||
|                 <div> | ||||
|                     <span className="font-medium">{index}.</span> {text} | ||||
|                 </div> | ||||
|  | ||||
|                 {code ? ( | ||||
|                     <div className="h-[60px]"> | ||||
|                         <CodeSnippet | ||||
|                             className="h-full" | ||||
|                             code={code} | ||||
|                             language={'shell'} | ||||
|                         /> | ||||
|                     </div> | ||||
|                 ) : null} | ||||
|                 {example ? ( | ||||
|                     <> | ||||
|                         <div className="my-2">Example:</div> | ||||
|                         <div className="h-[60px]"> | ||||
|                             <CodeSnippet | ||||
|                                 className="h-full" | ||||
|                                 code={example} | ||||
|                                 language={'shell'} | ||||
|                             /> | ||||
|                         </div> | ||||
|                     </> | ||||
|                 ) : null} | ||||
|             </div> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -0,0 +1,117 @@ | ||||
| import React from 'react'; | ||||
| import { DatabaseType } from '@/lib/domain/database-type'; | ||||
| import type { DatabaseEdition } from '@/lib/domain/database-edition'; | ||||
| import { DDLInstructionStep } from './ddl-instruction-step'; | ||||
|  | ||||
| interface DDLInstruction { | ||||
|     text: string; | ||||
|     code?: string; | ||||
|     example?: string; | ||||
| } | ||||
|  | ||||
| const DDLInstructionsMap: Record<DatabaseType, DDLInstruction[]> = { | ||||
|     [DatabaseType.GENERIC]: [], | ||||
|     [DatabaseType.MYSQL]: [ | ||||
|         { | ||||
|             text: 'Install mysqldump.', | ||||
|         }, | ||||
|         { | ||||
|             text: 'Execute the following command in your terminal (prefix with sudo on Linux if needed):', | ||||
|             code: `mysqldump -h <host> -u <username>\n-P <port> -p --no-data\n<database_name> > <output_path>`, | ||||
|             example: `mysqldump -h localhost -u root -P\n3306 -p --no-data my_db >\nschema_export.sql`, | ||||
|         }, | ||||
|         { | ||||
|             text: 'Open the exported SQL file, copy its contents, and paste them here.', | ||||
|         }, | ||||
|     ], | ||||
|     [DatabaseType.POSTGRESQL]: [ | ||||
|         { | ||||
|             text: 'Install pg_dump.', | ||||
|         }, | ||||
|         { | ||||
|             text: 'Execute the following command in your terminal (prefix with sudo on Linux if needed):', | ||||
|             code: `pg_dump -h <host> -p <port> -d <database_name> \n  -U <username> -s -F p -E UTF-8 \n  -f <output_file_path>`, | ||||
|             example: `pg_dump -h localhost -p 5432 -d my_db \n  -U postgres -s -F p -E UTF-8 \n  -f schema_export.sql`, | ||||
|         }, | ||||
|         { | ||||
|             text: 'Open the exported SQL file, copy its contents, and paste them here.', | ||||
|         }, | ||||
|     ], | ||||
|     [DatabaseType.SQLITE]: [ | ||||
|         { | ||||
|             text: 'Install sqlite3.', | ||||
|         }, | ||||
|         { | ||||
|             text: 'Execute the following command in your terminal:', | ||||
|             code: `sqlite3 <database_file_path>\n.dump > <output_file_path>`, | ||||
|             example: `sqlite3 my_db.db\n.dump > schema_export.sql`, | ||||
|         }, | ||||
|         { | ||||
|             text: 'Open the exported SQL file, copy its contents, and paste them here.', | ||||
|         }, | ||||
|     ], | ||||
|     [DatabaseType.SQL_SERVER]: [ | ||||
|         { | ||||
|             text: 'Download and install SQL Server Management Studio (SSMS).', | ||||
|         }, | ||||
|         { | ||||
|             text: 'Connect to your SQL Server instance using SSMS.', | ||||
|         }, | ||||
|         { | ||||
|             text: 'Right-click on the database you want to export and select Script Database as > CREATE To > New Query Editor Window.', | ||||
|         }, | ||||
|         { | ||||
|             text: 'Copy the generated script and paste it here.', | ||||
|         }, | ||||
|     ], | ||||
|     [DatabaseType.CLICKHOUSE]: [], | ||||
|     [DatabaseType.COCKROACHDB]: [ | ||||
|         { | ||||
|             text: 'Install pg_dump.', | ||||
|         }, | ||||
|         { | ||||
|             text: 'Execute the following command in your terminal (prefix with sudo on Linux if needed):', | ||||
|             code: `pg_dump -h <host> -p <port> -d <database_name> \n  -U <username> -s -F p -E UTF-8 \n  -f <output_file_path>`, | ||||
|             example: `pg_dump -h localhost -p 5432 -d my_db \n  -U postgres -s -F p -E UTF-8 \n  -f schema_export.sql`, | ||||
|         }, | ||||
|         { | ||||
|             text: 'Open the exported SQL file, copy its contents, and paste them here.', | ||||
|         }, | ||||
|     ], | ||||
|     [DatabaseType.MARIADB]: [ | ||||
|         { | ||||
|             text: 'Install mysqldump.', | ||||
|         }, | ||||
|         { | ||||
|             text: 'Execute the following command in your terminal (prefix with sudo on Linux if needed):', | ||||
|             code: `mysqldump -h <host> -u <username>\n-P <port> -p --no-data\n<database_name> > <output_path>`, | ||||
|             example: `mysqldump -h localhost -u root -P\n3306 -p --no-data my_db >\nschema_export.sql`, | ||||
|         }, | ||||
|         { | ||||
|             text: 'Open the exported SQL file, copy its contents, and paste them here.', | ||||
|         }, | ||||
|     ], | ||||
| }; | ||||
|  | ||||
| export interface DDLInstructionsProps { | ||||
|     databaseType: DatabaseType; | ||||
|     databaseEdition?: DatabaseEdition; | ||||
| } | ||||
|  | ||||
| export const DDLInstructions: React.FC<DDLInstructionsProps> = ({ | ||||
|     databaseType, | ||||
| }) => { | ||||
|     return ( | ||||
|         <> | ||||
|             {DDLInstructionsMap[databaseType].map((instruction, index) => ( | ||||
|                 <DDLInstructionStep | ||||
|                     key={index} | ||||
|                     index={index + 1} | ||||
|                     text={instruction.text} | ||||
|                     code={instruction.code} | ||||
|                     example={instruction.example} | ||||
|                 /> | ||||
|             ))} | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
| @@ -0,0 +1,147 @@ | ||||
| import React, { useEffect, useMemo, useState } from 'react'; | ||||
| import { DatabaseType } from '@/lib/domain/database-type'; | ||||
| import { CodeSnippet } from '@/components/code-snippet/code-snippet'; | ||||
| import type { DatabaseEdition } from '@/lib/domain/database-edition'; | ||||
| import { SSMSInfo } from './ssms-info/ssms-info'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { Tabs, TabsList, TabsTrigger } from '@/components/tabs/tabs'; | ||||
| import type { DatabaseClient } from '@/lib/domain/database-clients'; | ||||
| import { minimizeQuery } from '@/lib/data/import-metadata/scripts/minimize-script'; | ||||
| import { | ||||
|     databaseClientToLabelMap, | ||||
|     databaseTypeToClientsMap, | ||||
|     databaseEditionToClientsMap, | ||||
| } from '@/lib/domain/database-clients'; | ||||
| import type { ImportMetadataScripts } from '@/lib/data/import-metadata/scripts/scripts'; | ||||
|  | ||||
| export interface SmartQueryInstructionsProps { | ||||
|     databaseType: DatabaseType; | ||||
|     databaseEdition?: DatabaseEdition; | ||||
|     showSSMSInfoDialog: boolean; | ||||
|     setShowSSMSInfoDialog: (show: boolean) => void; | ||||
| } | ||||
|  | ||||
| export const SmartQueryInstructions: React.FC<SmartQueryInstructionsProps> = ({ | ||||
|     databaseType, | ||||
|     databaseEdition, | ||||
|     showSSMSInfoDialog, | ||||
|     setShowSSMSInfoDialog, | ||||
| }) => { | ||||
|     const databaseClients = useMemo( | ||||
|         () => [ | ||||
|             ...databaseTypeToClientsMap[databaseType], | ||||
|             ...(databaseEdition | ||||
|                 ? databaseEditionToClientsMap[databaseEdition] | ||||
|                 : []), | ||||
|         ], | ||||
|         [databaseType, databaseEdition] | ||||
|     ); | ||||
|     const [databaseClient, setDatabaseClient] = useState< | ||||
|         DatabaseClient | undefined | ||||
|     >(); | ||||
|     const { t } = useTranslation(); | ||||
|     const [importMetadataScripts, setImportMetadataScripts] = | ||||
|         useState<ImportMetadataScripts | null>(null); | ||||
|  | ||||
|     const code = useMemo( | ||||
|         () => | ||||
|             (databaseClients.length > 0 | ||||
|                 ? importMetadataScripts?.[databaseType]?.({ | ||||
|                       databaseEdition, | ||||
|                       databaseClient, | ||||
|                   }) | ||||
|                 : importMetadataScripts?.[databaseType]?.({ | ||||
|                       databaseEdition, | ||||
|                   })) ?? '', | ||||
|         [ | ||||
|             databaseType, | ||||
|             databaseEdition, | ||||
|             databaseClients, | ||||
|             importMetadataScripts, | ||||
|             databaseClient, | ||||
|         ] | ||||
|     ); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         const loadScripts = async () => { | ||||
|             const { importMetadataScripts } = await import( | ||||
|                 '@/lib/data/import-metadata/scripts/scripts' | ||||
|             ); | ||||
|             setImportMetadataScripts(importMetadataScripts); | ||||
|         }; | ||||
|         loadScripts(); | ||||
|     }, []); | ||||
|  | ||||
|     return ( | ||||
|         <> | ||||
|             <div className="flex flex-col gap-1"> | ||||
|                 <div className="flex flex-col gap-1 text-sm text-primary"> | ||||
|                     <div> | ||||
|                         <span className="font-medium">1.</span>{' '} | ||||
|                         {t('new_diagram_dialog.import_database.step_1')} | ||||
|                     </div> | ||||
|                     {databaseType === DatabaseType.SQL_SERVER && ( | ||||
|                         <SSMSInfo | ||||
|                             open={showSSMSInfoDialog} | ||||
|                             setOpen={setShowSSMSInfoDialog} | ||||
|                         /> | ||||
|                     )} | ||||
|                 </div> | ||||
|                 {databaseClients.length > 0 ? ( | ||||
|                     <Tabs | ||||
|                         value={!databaseClient ? 'dbclient' : databaseClient} | ||||
|                         onValueChange={(value) => { | ||||
|                             setDatabaseClient( | ||||
|                                 value === 'dbclient' | ||||
|                                     ? undefined | ||||
|                                     : (value as DatabaseClient) | ||||
|                             ); | ||||
|                         }} | ||||
|                     > | ||||
|                         <div className="flex flex-1"> | ||||
|                             <TabsList className="h-8 justify-start rounded-none rounded-t-sm "> | ||||
|                                 <TabsTrigger | ||||
|                                     value="dbclient" | ||||
|                                     className="h-6 w-20" | ||||
|                                 > | ||||
|                                     DB Client | ||||
|                                 </TabsTrigger> | ||||
|  | ||||
|                                 {databaseClients?.map((client) => ( | ||||
|                                     <TabsTrigger | ||||
|                                         key={client} | ||||
|                                         value={client} | ||||
|                                         className="h-6 !w-20" | ||||
|                                     > | ||||
|                                         {databaseClientToLabelMap[client]} | ||||
|                                     </TabsTrigger> | ||||
|                                 )) ?? []} | ||||
|                             </TabsList> | ||||
|                         </div> | ||||
|                         <CodeSnippet | ||||
|                             className="h-40 w-full md:h-[200px]" | ||||
|                             loading={!importMetadataScripts} | ||||
|                             code={minimizeQuery(code)} | ||||
|                             codeToCopy={code} | ||||
|                             language={databaseClient ? 'shell' : 'sql'} | ||||
|                         /> | ||||
|                     </Tabs> | ||||
|                 ) : ( | ||||
|                     <CodeSnippet | ||||
|                         className="h-40 w-full flex-auto md:h-[200px]" | ||||
|                         loading={!importMetadataScripts} | ||||
|                         code={minimizeQuery(code)} | ||||
|                         codeToCopy={code} | ||||
|                         language="sql" | ||||
|                     /> | ||||
|                 )} | ||||
|             </div> | ||||
|             <div className="flex flex-col gap-1"> | ||||
|                 <p className="text-sm text-primary"> | ||||
|                     <span className="font-medium">2.</span>{' '} | ||||
|                     {t('new_diagram_dialog.import_database.step_2')} | ||||
|                 </p> | ||||
|             </div> | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
| @@ -4,32 +4,55 @@ import { | ||||
|     HoverCardTrigger, | ||||
| } from '@/components/hover-card/hover-card'; | ||||
| import { Label } from '@/components/label/label'; | ||||
| import { Info } from 'lucide-react'; | ||||
| import React from 'react'; | ||||
| import { Info, X } from 'lucide-react'; | ||||
| import React, { useCallback, useEffect, useMemo } from 'react'; | ||||
| import SSMSInstructions from '@/assets/ssms-instructions.png'; | ||||
| import { ZoomableImage } from '@/components/zoomable-image/zoomable-image'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| 
 | ||||
| export interface SSMSInfoProps {} | ||||
| export interface SSMSInfoProps { | ||||
|     open?: boolean; | ||||
|     setOpen?: (open: boolean) => void; | ||||
| } | ||||
| 
 | ||||
| export const SSMSInfo = React.forwardRef< | ||||
|     React.ElementRef<typeof HoverCardTrigger>, | ||||
|     SSMSInfoProps | ||||
| >((props, ref) => { | ||||
| >(({ open: controlledOpen, setOpen: setControlledOpen }, ref) => { | ||||
|     const [open, setOpen] = React.useState(false); | ||||
|     const { t } = useTranslation(); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         if (controlledOpen) { | ||||
|             setOpen(true); | ||||
|         } | ||||
|     }, [controlledOpen]); | ||||
| 
 | ||||
|     const closeHandler = useCallback(() => { | ||||
|         setOpen(false); | ||||
|         setControlledOpen?.(false); | ||||
|     }, [setControlledOpen]); | ||||
| 
 | ||||
|     const isOpen = useMemo( | ||||
|         () => open || controlledOpen, | ||||
|         [open, controlledOpen] | ||||
|     ); | ||||
| 
 | ||||
|     return ( | ||||
|         <HoverCard | ||||
|             open={open} | ||||
|             open={isOpen} | ||||
|             onOpenChange={(isOpen) => { | ||||
|                 if (controlledOpen) { | ||||
|                     return; | ||||
|                 } | ||||
|                 setOpen(isOpen); | ||||
|             }} | ||||
|         > | ||||
|             <HoverCardTrigger ref={ref} {...props} asChild> | ||||
|             <HoverCardTrigger ref={ref} asChild> | ||||
|                 <div | ||||
|                     className="flex flex-row items-center gap-1 text-pink-600" | ||||
|                     onClick={() => { | ||||
|                         setOpen(!open); | ||||
|                         setOpen?.(!open); | ||||
|                     }} | ||||
|                 > | ||||
|                     <Info size={14} /> | ||||
| @@ -41,13 +64,21 @@ export const SSMSInfo = React.forwardRef< | ||||
|                 </div> | ||||
|             </HoverCardTrigger> | ||||
|             <HoverCardContent className="w-80"> | ||||
|                 <div className="flex"> | ||||
|                     <div className="space-y-1"> | ||||
|                 <div className="flex flex-col"> | ||||
|                     <div className="flex items-start justify-between"> | ||||
|                         <h4 className="text-sm font-semibold"> | ||||
|                             {t( | ||||
|                                 'new_diagram_dialog.import_database.ssms_instructions.title' | ||||
|                             )} | ||||
|                         </h4> | ||||
|                         <button | ||||
|                             onClick={closeHandler} | ||||
|                             className="text-muted-foreground hover:text-foreground" | ||||
|                         > | ||||
|                             <X size={16} /> | ||||
|                         </button> | ||||
|                     </div> | ||||
|                     <div className="space-y-1"> | ||||
|                         <p className="text-xs text-muted-foreground"> | ||||
|                             <span className="font-semibold">1. </span> | ||||
|                             {t( | ||||
| @@ -17,6 +17,7 @@ import { CreateDiagramDialogStep } from './create-diagram-dialog-step'; | ||||
| import { ImportDatabase } from '../common/import-database/import-database'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import type { BaseDialogProps } from '../common/base-dialog-props'; | ||||
| import { sqlImportToDiagram } from '@/lib/data/sql-import'; | ||||
|  | ||||
| export interface CreateDiagramDialogProps extends BaseDialogProps {} | ||||
|  | ||||
| @@ -25,10 +26,11 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({ | ||||
| }) => { | ||||
|     const { diagramId } = useChartDB(); | ||||
|     const { t } = useTranslation(); | ||||
|     const [importMethod, setImportMethod] = useState<'query' | 'ddl'>('query'); | ||||
|     const [databaseType, setDatabaseType] = useState<DatabaseType>( | ||||
|         DatabaseType.GENERIC | ||||
|     ); | ||||
|     const { closeCreateDiagramDialog } = useDialog(); | ||||
|     const { closeCreateDiagramDialog, openImportDBMLDialog } = useDialog(); | ||||
|     const { updateConfig } = useConfig(); | ||||
|     const [scriptResult, setScriptResult] = useState(''); | ||||
|     const [databaseEdition, setDatabaseEdition] = useState< | ||||
| @@ -41,6 +43,11 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({ | ||||
|     const [diagramNumber, setDiagramNumber] = useState<number>(1); | ||||
|     const navigate = useNavigate(); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         setDatabaseEdition(undefined); | ||||
|         setImportMethod('query'); | ||||
|     }, [databaseType]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         const fetchDiagrams = async () => { | ||||
|             const diagrams = await listDiagrams(); | ||||
| @@ -54,29 +61,41 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({ | ||||
|         setDatabaseType(DatabaseType.GENERIC); | ||||
|         setDatabaseEdition(undefined); | ||||
|         setScriptResult(''); | ||||
|         setImportMethod('query'); | ||||
|     }, [dialog.open]); | ||||
|  | ||||
|     const hasExistingDiagram = (diagramId ?? '').trim().length !== 0; | ||||
|  | ||||
|     const importNewDiagram = useCallback(async () => { | ||||
|         const databaseMetadata: DatabaseMetadata = | ||||
|             loadDatabaseMetadata(scriptResult); | ||||
|         let diagram: Diagram | undefined; | ||||
|  | ||||
|         const diagram = await loadFromDatabaseMetadata({ | ||||
|             databaseType, | ||||
|             databaseMetadata, | ||||
|             diagramNumber, | ||||
|             databaseEdition: | ||||
|                 databaseEdition?.trim().length === 0 | ||||
|                     ? undefined | ||||
|                     : databaseEdition, | ||||
|         }); | ||||
|         if (importMethod === 'ddl') { | ||||
|             diagram = await sqlImportToDiagram({ | ||||
|                 sqlContent: scriptResult, | ||||
|                 sourceDatabaseType: databaseType, | ||||
|                 targetDatabaseType: databaseType, | ||||
|             }); | ||||
|         } else { | ||||
|             const databaseMetadata: DatabaseMetadata = | ||||
|                 loadDatabaseMetadata(scriptResult); | ||||
|  | ||||
|             diagram = await loadFromDatabaseMetadata({ | ||||
|                 databaseType, | ||||
|                 databaseMetadata, | ||||
|                 diagramNumber, | ||||
|                 databaseEdition: | ||||
|                     databaseEdition?.trim().length === 0 | ||||
|                         ? undefined | ||||
|                         : databaseEdition, | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         await addDiagram({ diagram }); | ||||
|         await updateConfig({ defaultDiagramId: diagram.id }); | ||||
|         closeCreateDiagramDialog(); | ||||
|         navigate(`/diagrams/${diagram.id}`); | ||||
|     }, [ | ||||
|         importMethod, | ||||
|         databaseType, | ||||
|         addDiagram, | ||||
|         databaseEdition, | ||||
| @@ -104,6 +123,10 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({ | ||||
|         await updateConfig({ defaultDiagramId: diagram.id }); | ||||
|         closeCreateDiagramDialog(); | ||||
|         navigate(`/diagrams/${diagram.id}`); | ||||
|         setTimeout( | ||||
|             () => openImportDBMLDialog({ withCreateEmptyDiagram: true }), | ||||
|             700 | ||||
|         ); | ||||
|     }, [ | ||||
|         databaseType, | ||||
|         addDiagram, | ||||
| @@ -112,6 +135,7 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({ | ||||
|         navigate, | ||||
|         updateConfig, | ||||
|         diagramNumber, | ||||
|         openImportDBMLDialog, | ||||
|     ]); | ||||
|  | ||||
|     return ( | ||||
| @@ -128,7 +152,7 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({ | ||||
|             }} | ||||
|         > | ||||
|             <DialogContent | ||||
|                 className="flex max-h-screen w-[90vw] max-w-[90vw] flex-col overflow-y-auto md:overflow-visible lg:max-w-[60vw] xl:lg:max-w-lg xl:min-w-[45vw]" | ||||
|                 className="flex max-h-dvh w-full flex-col md:max-w-[900px]" | ||||
|                 showClose={hasExistingDiagram} | ||||
|             > | ||||
|                 {step === CreateDiagramDialogStep.SELECT_DATABASE ? ( | ||||
| @@ -154,6 +178,8 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({ | ||||
|                         } | ||||
|                         setScriptResult={setScriptResult} | ||||
|                         title={t('new_diagram_dialog.import_database.title')} | ||||
|                         importMethod={importMethod} | ||||
|                         setImportMethod={setImportMethod} | ||||
|                     /> | ||||
|                 )} | ||||
|             </DialogContent> | ||||
|   | ||||