diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d710c09a..b27646ca 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -9,7 +9,10 @@ module.exports = { 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:import/recommended', - 'plugin:import/typescript' + 'plugin:import/typescript', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:react-hooks/recommended' ], parser: '@typescript-eslint/parser', parserOptions: { @@ -26,10 +29,52 @@ module.exports = { project: 'tsconfig.json', }, }, + "react": { + "createClass": "createReactClass", // Regex for Component Factory to use, + // default to "createReactClass" + "pragma": "React", // Pragma to use, default to "React" + "fragment": "Fragment", // Fragment to use (may be a property of ), default to "Fragment" + "version": "detect", // React version. "detect" automatically picks the version you have installed. + // You can also use `16.0`, `16.3`, etc, if you want to override the detected value. + // Defaults to the "defaultVersion" setting and warns if missing, and to "detect" in the future + "defaultVersion": "", // Default React version to use when the version you have installed cannot be detected. + // If not provided, defaults to the latest React version. + "flowVersion": "0.53" // Flow version + }, + "propWrapperFunctions": [ + // The names of any function used to wrap propTypes, e.g. `forbidExtraProps`. If this isn't set, any propTypes wrapped in a function will be skipped. + "forbidExtraProps", + {"property": "freeze", "object": "Object"}, + {"property": "myFavoriteWrapper"}, + // for rules that check exact prop wrappers + {"property": "forbidExtraProps", "exact": true} + ], + "componentWrapperFunctions": [ + // The name of any function used to wrap components, e.g. Mobx `observer` function. If this isn't set, components wrapped by these functions will be skipped. + "observer", // `property` + {"property": "styled"}, // `object` is optional + {"property": "observer", "object": "Mobx"}, + {"property": "observer", "object": ""} // sets `object` to whatever value `settings.react.pragma` is set to + ], + "formComponents": [ + // Components used as alternatives to
for forms, eg. + "CustomForm", + {"name": "SimpleForm", "formAttribute": "endpoint"}, + {"name": "Form", "formAttribute": ["registerEndpoint", "loginEndpoint"]}, // allows specifying multiple properties if necessary + ], + "linkComponents": [ + // Components used as alternatives to for linking, eg. + "Hyperlink", + {"name": "MyLink", "linkAttribute": "to"}, + {"name": "Link", "linkAttribute": ["to", "href"]}, // allows specifying multiple properties if necessary + ] + }, rules: { 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'error', + 'react/display-name': 'off', + 'react/prop-types': 'off', '@typescript-eslint/adjacent-overload-signatures': 'error', '@typescript-eslint/no-empty-function': 'error', '@typescript-eslint/no-empty-interface': 'error', diff --git a/.vscode/settings.json b/.vscode/settings.json index 7d669850..fc8166b5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,8 +3,8 @@ "editor.rulers": [ 120 ], - "javascript.preferences.importModuleSpecifier": "relative", - "typescript.preferences.importModuleSpecifier": "relative", + "javascript.preferences.importModuleSpecifier": "non-relative", + "typescript.preferences.importModuleSpecifier": "non-relative", // Disable the default formatter, use eslint instead "search.followSymlinks": false, "search.exclude": { @@ -17,75 +17,28 @@ "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit", - "source.organizeImports": "never" + "source.organizeImports": "explicit" }, + "eslint.workingDirectories": [ { "mode": "auto" } ], - // Enable the ESlint flat config support - "eslint.useFlatConfig": true, - // Silent the stylistic rules in you IDE, but still auto fix them - "eslint.rules.customizations": [ - { - "rule": "style/*", - "severity": "off" - }, - { - "rule": "format/*", - "severity": "off" - }, - { - "rule": "*-indent", - "severity": "off" - }, - { - "rule": "*-spacing", - "severity": "off" - }, - { - "rule": "*-spaces", - "severity": "off" - }, - { - "rule": "*-order", - "severity": "off" - }, - { - "rule": "*-dangle", - "severity": "off" - }, - { - "rule": "*-newline", - "severity": "off" - }, - { - "rule": "*quotes", - "severity": "off" - }, - { - "rule": "*semi", - "severity": "off" - } - ], - // Enable eslint for all supported languages + "eslint.useFlatConfig": false, + "eslint.format.enable": true, + "eslint.debug": true, "eslint.validate": [ "javascript", "javascriptreact", "typescript", - "typescriptreact", - "vue", - "html", - "markdown", - "json", - "jsonc", - "yaml", - "toml", - "less", - "css" + "typescriptreact" ], + "eslint.options": { + "overrideConfigFile": "./.eslintrc.cjs" + }, "editor.quickSuggestions": { "strings": "on" - } + }, + "files.autoSave": "afterDelay" } \ No newline at end of file diff --git a/cypress/downloads/downloads.htm b/cypress/downloads/downloads.htm new file mode 100644 index 00000000..a73e0746 Binary files /dev/null and b/cypress/downloads/downloads.htm differ diff --git a/package.json b/package.json index 7044188d..93570f83 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,13 @@ "dependencies": { "@appflowyinc/ai-chat": "0.1.26", "@appflowyinc/editor": "^0.1.10", + "@atlaskit/pragmatic-drag-and-drop": "1.5.2", + "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.0", + "@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.2", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", + "@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.0", + "@atlaskit/pragmatic-drag-and-drop-react-accessibility": "2.0.3", + "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": "3.1.0", "@atlaskit/primitives": "^5.5.3", "@emoji-mart/data": "^1.1.2", "@emoji-mart/react": "^1.1.1", @@ -43,20 +50,26 @@ "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.2.2", + "@radix-ui/react-tabs": "^1.1.9", "@radix-ui/react-tooltip": "^1.1.8", "@reduxjs/toolkit": "2.0.0", "@slate-yjs/core": "^1.0.2", + "@tanstack/react-virtual": "^3.13.6", + "@types/big.js": "^6.2.2", "@types/react-swipeable-views": "^0.13.4", "async-retry": "^1.3.3", - "axios": "^1.6.8", + "axios": "^1.9.0", + "big.js": "^7.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "colorthief": "^2.4.0", + "date-fns": "^4.1.0", "dayjs": "^1.11.9", - "decimal.js": "^10.4.3", "dexie": "^4.0.7", "dexie-react-hooks": "^1.1.7", "dompurify": "^3.1.7", + "downloadjs": "^1.4.7", "emoji-mart": "^5.5.2", "emoji-regex": "^10.2.1", "escape-string-regexp": "^5.0.0", @@ -75,7 +88,6 @@ "js-md5": "^0.8.3", "katex": "^0.16.7", "lightgallery": "^2.7.2", - "link-preview-js": "^3.0.14", "lodash-es": "^4.17.21", "lucide-react": "^0.485.0", "mermaid": "^11.4.1", @@ -94,8 +106,10 @@ "react-custom-scrollbars": "^4.2.1", "react-custom-scrollbars-2": "^4.5.0", "react-datepicker": "^4.23.0", + "react-day-picker": "8.10.1", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.13", + "react-grid-dnd": "^2.1.2", "react-helmet": "^6.1.0", "react-hook-form": "^7.52.2", "react-hot-toast": "^2.4.1", @@ -144,6 +158,7 @@ "@testing-library/react": "^16.0.0", "@types/cypress-image-snapshot": "^3.1.9", "@types/dompurify": "^3.0.5", + "@types/downloadjs": "^1.4.6", "@types/google-protobuf": "^3.15.12", "@types/is-hotkey": "^0.1.7", "@types/jest": "^29.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0690c9e..16a0b76c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,27 @@ importers: '@appflowyinc/editor': specifier: ^0.1.10 version: 0.1.10(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.3.1(react@18.3.1))(react-i18next@14.1.3(i18next@22.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(slate-history@0.100.0(slate@0.101.5))(slate-react@0.101.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(slate@0.101.5))(slate@0.101.5)(ts-node@10.9.2(@types/node@20.17.47)(typescript@4.9.5)) + '@atlaskit/pragmatic-drag-and-drop': + specifier: 1.5.2 + version: 1.5.2 + '@atlaskit/pragmatic-drag-and-drop-auto-scroll': + specifier: 2.1.0 + version: 2.1.0 + '@atlaskit/pragmatic-drag-and-drop-flourish': + specifier: 2.0.2 + version: 2.0.2(@types/react@18.3.21)(react@18.3.1) + '@atlaskit/pragmatic-drag-and-drop-hitbox': + specifier: 1.0.3 + version: 1.0.3 + '@atlaskit/pragmatic-drag-and-drop-live-region': + specifier: 1.3.0 + version: 1.3.0 + '@atlaskit/pragmatic-drag-and-drop-react-accessibility': + specifier: 2.0.3 + version: 2.0.3(@babel/core@7.27.1)(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@atlaskit/pragmatic-drag-and-drop-react-drop-indicator': + specifier: 3.1.0 + version: 3.1.0(@types/react@18.3.21)(react@18.3.1) '@atlaskit/primitives': specifier: ^5.5.3 version: 5.7.0(@types/react@18.3.21)(react@18.3.1) @@ -43,7 +64,7 @@ importers: version: 6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mui/x-date-pickers-pro': specifier: ^6.18.2 - version: 6.20.2(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@mui/material@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(date-fns@2.30.0)(dayjs@1.11.13)(luxon@3.6.1)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 6.20.2(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@mui/material@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(date-fns@4.1.0)(dayjs@1.11.13)(luxon@3.6.1)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-avatar': specifier: ^1.1.3 version: 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -65,6 +86,12 @@ importers: '@radix-ui/react-slot': specifier: ^1.1.2 version: 1.2.2(@types/react@18.3.21)(react@18.3.1) + '@radix-ui/react-switch': + specifier: ^1.2.2 + version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tabs': + specifier: ^1.1.9 + version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-tooltip': specifier: ^1.1.8 version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -74,6 +101,12 @@ importers: '@slate-yjs/core': specifier: ^1.0.2 version: 1.0.2(slate@0.101.5)(yjs@14.0.0-1) + '@tanstack/react-virtual': + specifier: ^3.13.6 + version: 3.13.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/big.js': + specifier: ^6.2.2 + version: 6.2.2 '@types/react-swipeable-views': specifier: ^0.13.4 version: 0.13.6 @@ -81,8 +114,11 @@ importers: specifier: ^1.3.3 version: 1.3.3 axios: - specifier: ^1.6.8 + specifier: ^1.9.0 version: 1.9.0 + big.js: + specifier: ^7.0.1 + version: 7.0.1 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -92,12 +128,12 @@ importers: colorthief: specifier: ^2.4.0 version: 2.6.0 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 dayjs: specifier: ^1.11.9 version: 1.11.13 - decimal.js: - specifier: ^10.4.3 - version: 10.5.0 dexie: specifier: ^4.0.7 version: 4.0.11 @@ -107,6 +143,9 @@ importers: dompurify: specifier: ^3.1.7 version: 3.2.5 + downloadjs: + specifier: ^1.4.7 + version: 1.4.7 emoji-mart: specifier: ^5.5.2 version: 5.6.0 @@ -161,9 +200,6 @@ importers: lightgallery: specifier: ^2.7.2 version: 2.8.3 - link-preview-js: - specifier: ^3.0.14 - version: 3.0.14 lodash-es: specifier: ^4.17.21 version: 4.17.21 @@ -218,12 +254,18 @@ importers: react-datepicker: specifier: ^4.23.0 version: 4.25.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-day-picker: + specifier: 8.10.1 + version: 8.10.1(date-fns@4.1.0)(react@18.3.1) react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) react-error-boundary: specifier: ^4.0.13 version: 4.1.2(react@18.3.1) + react-grid-dnd: + specifier: ^2.1.2 + version: 2.1.2(react-dom@18.3.1(react@18.3.1))(react-gesture-responder@2.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) react-helmet: specifier: ^6.1.0 version: 6.1.0(react@18.3.1) @@ -363,6 +405,9 @@ importers: '@types/dompurify': specifier: ^3.0.5 version: 3.2.0 + '@types/downloadjs': + specifier: ^1.4.6 + version: 1.4.6 '@types/google-protobuf': specifier: ^3.15.12 version: 3.15.12 @@ -616,6 +661,12 @@ packages: peerDependencies: react: ^16.8.0 + '@atlaskit/analytics-next@11.0.0': + resolution: {integrity: sha512-ekts8cC3nBc3ye1ivsDQXLyWCQSlMN0uKTuspVa9ppydt8r8qvJHyI/ACX3DS5uRH/du0f2mZuhdrvdBibr0yQ==} + peerDependencies: + react: ^18.2.0 + react-dom: ^18.2.0 + '@atlaskit/analytics-next@9.3.4': resolution: {integrity: sha512-N8icz0+9OUwFPpR5lOzUnB9Kcm8bPjH2ZCeCbi+vmexWw3JLunQ1ah9w5W/TeRPpLPR23Y/4ntEaROGdeb07nQ==} peerDependencies: @@ -626,6 +677,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + '@atlaskit/app-provider@2.2.0': + resolution: {integrity: sha512-tPrbGhhBAmPN5zWsjv7vPgOnr0JXtbjEIYVoiDulSHempWXSm8ZWgx43WDDb+jhCNdvJQ02TxazPMLYVMziNfw==} + peerDependencies: + react: ^18.2.0 + '@atlaskit/atlassian-context@0.2.0': resolution: {integrity: sha512-msLRSp0qck6eflkShplgyIoOogNKxKRc6QIWGQlSvKGxHQNEbLEkRGcDzdh8PuBxSs1gda7OqYrdtQYQiPbpTQ==} peerDependencies: @@ -636,6 +692,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ~18.2.0 + '@atlaskit/css@0.10.6': + resolution: {integrity: sha512-oSv0CMSq70NSy2UwGi4qhX0VDvZQe4IY6J4sEo5uOg7UoUChpyp8rfz8KZ5m3GqM5GyoSOw9lYhMyMNZCuY0/Q==} + peerDependencies: + react: ^18.2.0 + '@atlaskit/ds-lib@2.7.0': resolution: {integrity: sha512-+U4aPE2dBRFUBYWCM9vkrwJGrLAYVcK30ZpEiQDNpM2D7K41223TfEijC8azaN4VM3tsCgwSKBWwniloNxqYbA==} peerDependencies: @@ -646,20 +707,75 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + '@atlaskit/ds-lib@4.0.0': + resolution: {integrity: sha512-nM0wAo8bm7FyAYuId6Ba2MFnLSVzlsZpyfRxPJ7dMFqEhJ4R53/CoT8v18mvi2Hu1Y4+d1t97lOyybHfgFyb+A==} + peerDependencies: + react: ^18.2.0 + '@atlaskit/feature-gate-js-client@5.3.0': resolution: {integrity: sha512-eSW3ZwAIvpSfLnch7+zuxyMvIPFVrfEAX0PNj7Zg70Epm3RJ4Dl62szAhmZrp5xxx5eeVj3BnlIk5Fq7rWY6WQ==} + '@atlaskit/focus-ring@3.0.0': + resolution: {integrity: sha512-6wl45BRPBLUkO6W5tf7Yd03eqAJdwtHq7W00v1EWJ/zd3xZqmmjBGBbUUkBgLGZbUFKJMbYDFqfUKFZHcJWFtw==} + peerDependencies: + react: ^18.2.0 + + '@atlaskit/icon@25.8.0': + resolution: {integrity: sha512-f/lXX0vMX/sNXPDDjcFa0meJr5+Y3dNnvalf5Z/DAoMQC1zLpKr/jzb6OdV/4R7psK2itIVwLPV5aSxn9I9U2A==} + peerDependencies: + react: ^18.2.0 + '@atlaskit/interaction-context@2.6.0': resolution: {integrity: sha512-xbXrwyutOujnlP8T/Lyq2NSHbGTTOsyRfaZT2YzhaSroWmNFltESdjrFM0DqLkHtMk3QrR4VjEp1kziBPV7Rtw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + '@atlaskit/interaction-context@3.0.0': + resolution: {integrity: sha512-s2OvErvOWlDxw75kvANbsiosC8dxyXg+3A2CORYa3HigUnDg5unaM5oDNig8em0/2m9d9TYvrRuamjY0tHFnPQ==} + peerDependencies: + react: ^18.2.0 + + '@atlaskit/motion@5.1.3': + resolution: {integrity: sha512-3bKBExBIx6QSIjx6cDefr+VryJ5pwOx8eBP9eXD1gBUeL+Wq8xVlilL5bIOjJv2F2MYOvtmYfMSfOLcxJGidDQ==} + peerDependencies: + react: ^18.2.0 + '@atlaskit/platform-feature-flags@0.3.0': resolution: {integrity: sha512-/0u5fFJ0Rw2j4M5wzsXgaHO6Ey12oekPCDTRvmmAIp4GO9T2Swbl80bavLAPSOmSHMhHTSuvRxiJveZXfQ21IQ==} '@atlaskit/platform-feature-flags@1.1.1': resolution: {integrity: sha512-YKuy3RsqCEoNALiMHVma0GGHkzZMSIBsEgZlV/2TPw65QRzOWJvKA3ZIKucmXzr3m7AUqg1XHwXvVlUuNZhUgg==} + '@atlaskit/pragmatic-drag-and-drop-auto-scroll@2.1.0': + resolution: {integrity: sha512-E52y8/0BTTf4ai6BJyFYgdVHFgQ1AES33KvAVQpZ41jMkoukLIq6UoCudOXku7xs3qoPygQdpC+vitVUuEFJXw==} + + '@atlaskit/pragmatic-drag-and-drop-flourish@2.0.2': + resolution: {integrity: sha512-BcekPRucovhxJN7K11MHOwR83nshNnEH8PeSoNkMG+aX0obTa4IOV5ChE5RE5tUFKxej5eSIF9s/ZEh6vwWhJA==} + + '@atlaskit/pragmatic-drag-and-drop-hitbox@1.0.3': + resolution: {integrity: sha512-/Sbu/HqN2VGLYBhnsG7SbRNg98XKkbF6L7XDdBi+izRybfaK1FeMfodPpm/xnBHPJzwYMdkE0qtLyv6afhgMUA==} + + '@atlaskit/pragmatic-drag-and-drop-live-region@1.3.0': + resolution: {integrity: sha512-Ns60mU2Ll4MqfOWjhBJN82B7N5+kYNjiWz0Gm7WgSPUrqzqDf9yNSjkcKsZyxJorJjUy/rADx9u3wWzFqVQ4Yw==} + + '@atlaskit/pragmatic-drag-and-drop-react-accessibility@2.0.3': + resolution: {integrity: sha512-IUx5mVWo33SEAQY+2Fa3mRaGJMREKmpcKkSXf086cPX7VWa8WPZjwszDXe4d4oiFuP7565/YUfIKwufyLF1c/g==} + peerDependencies: + react: ^18.2.0 || ^19.0.0 + + '@atlaskit/pragmatic-drag-and-drop-react-drop-indicator@3.1.0': + resolution: {integrity: sha512-rLZphd7/wo+ol5mj0jyo/rVG83A19EuRSfckx+Lk0otb93GBCyBT0NriQwauHalqMxubVjqi+i1WFn6G7KajUw==} + peerDependencies: + react: ^18.2.0 || ^19.0.0 + + '@atlaskit/pragmatic-drag-and-drop@1.5.2': + resolution: {integrity: sha512-fDuTwlDD11r3ev5tLJ6JnzQUiG9v77c8zGcNdO7RRNtZZbOHam8CFhmyFGY4E/mLjvgYng0UkcyCrSBc4FXYZw==} + + '@atlaskit/primitives@14.8.2': + resolution: {integrity: sha512-XZmjEMeYXpCR64A26oqs8qRtrVBKey6PYPDRP5AinMKGUWz/1Un4dLO5kgdcvK9jjlYAemnAWJv6GhpFQRfwNQ==} + peerDependencies: + react: ^18.2.0 + '@atlaskit/primitives@5.7.0': resolution: {integrity: sha512-eCLyHN1BllNpwqA2YqCmYpqwoiNVcW3R6bHrpKmsW8uvPE/+Bd45hOiPwvCPJUPyK1ZNMfnkegWKkoxcmjMYIQ==} peerDependencies: @@ -675,11 +791,21 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + '@atlaskit/tokens@4.9.1': + resolution: {integrity: sha512-f8BvV+ZAi+0NV7xZzO12Fe4C/sPSXKaalwYOuO9tIjOeaR3ot6vY//rDGP/rKCC+HBsY6GoLcIsBs3UnERxyGQ==} + peerDependencies: + react: ^18.2.0 + '@atlaskit/visually-hidden@1.6.0': resolution: {integrity: sha512-czJDoFENmVAhy0ZUDhBkjS/SdO3q5e6qSvlueBgZf0Nf6AV7niStZUAt56Kd+OujM7O+pscIdaME7Mks5D5sJQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + '@atlaskit/visually-hidden@3.0.3': + resolution: {integrity: sha512-tU2KX0Hdn4DTL0DejO5j27RuySBdWRQQNVEvmNs20F5YnVQla/wGt1a85Y0widuBOn3H/RgTRuHPsChk9fimHg==} + peerDependencies: + react: ^18.2.0 + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -1343,6 +1469,11 @@ packages: peerDependencies: react: '>= 16.12.0' + '@compiled/react@0.18.4': + resolution: {integrity: sha512-tO0cAZTOMky8IcBscs6VQsMRUos8nXixIsQWPY8XWbF5JYYkIxuJ0ojpkjdUBkXBdgdjBjp6kczoub8BLPjHZg==} + peerDependencies: + react: '>= 16.12.0' + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -2223,6 +2354,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: @@ -2411,6 +2555,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-radio-group@1.3.6': resolution: {integrity: sha512-1tfTAqnYZNVwSpFhCT273nzK8qGBReeYnNTPspCggqk1fvIrfVxJekIuBFidNivzpdiMqDwVGnQvHqXrRPM4Og==} peerDependencies: @@ -2424,6 +2581,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.10': + resolution: {integrity: sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.9': resolution: {integrity: sha512-ZzrIFnMYHHCNqSNCsuN6l7wlewBEq0O0BCSBkabJMFXVO51LRUTq71gLP1UxFvmrXElqmPjA5VX7IqC9VpazAQ==} peerDependencies: @@ -2472,6 +2642,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-switch@1.2.4': resolution: {integrity: sha512-yZCky6XZFnR7pcGonJkr9VyNRu46KcYAbyg1v/gVVCZUr8UJ4x+RpncC27hHtiZ15jC+3WS8Yg/JSgyIHnYYsQ==} peerDependencies: @@ -2485,6 +2664,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tabs@1.1.12': + resolution: {integrity: sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-toast@1.2.13': resolution: {integrity: sha512-e/e43mQAwgYs8BY4y9l99xTK6ig1bK2uXsFLOMn9IZ16lAgulSTsotcPHVT2ZlSb/ye6Sllq7IgyDB8dGhpeXQ==} peerDependencies: @@ -2848,6 +3040,15 @@ packages: peerDependencies: '@svgr/core': '*' + '@tanstack/react-virtual@3.13.9': + resolution: {integrity: sha512-SPWC8kwG/dWBf7Py7cfheAPOxuvIv4fFQ54PdmYbg7CpXfsKxkucak43Q0qKsxVthhUJQ1A7CIMAIplq4BjVwA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.13.9': + resolution: {integrity: sha512-3jztt0jpaoJO5TARe2WIHC1UQC3VMLAFUW5mmMo0yrkwtDB2AQP0+sh10BVUpWrnvHjSLvzFizydtEGLCJKFoQ==} + '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -2908,6 +3109,9 @@ packages: '@types/babel__traverse@7.20.7': resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/big.js@6.2.2': + resolution: {integrity: sha512-e2cOW9YlVzFY2iScnGBBkplKsrn2CsObHQ2Hiw4V1sSyiGbgWL8IyqE3zFi1Pt5o1pdAtYkDAIsF3KKUPjdzaA==} + '@types/cypress-image-snapshot@3.1.9': resolution: {integrity: sha512-vcFgB8AfuM0dtupCHAGgapUbjRNWmMDtpzzpXz5gexWOHW4y/n10XRi5/y5Xpi0Ztku+X4dnRVMqQa5IDcy3Ig==} @@ -3014,6 +3218,9 @@ packages: resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. + '@types/downloadjs@1.4.6': + resolution: {integrity: sha512-mp3w70vsaiLRT9ix92fmI9Ob2yJAPZm6tShJtofo2uHbN11G2i6a0ApIEjBl/kv3e9V7Pv7jMjk1bUwYWvMHvA==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -3115,6 +3322,11 @@ packages: '@types/react-datepicker@4.19.6': resolution: {integrity: sha512-uH5fzxt9eXxnc+hDCy/iRSFqU2+9lR/q2lAmaG4WILMai1o3IOdpcV+VSypzBFJLTEC2jrfeDXcdol0CJVMq4g==} + '@types/react-dom@16.9.25': + resolution: {integrity: sha512-ZK//eAPhwft9Ul2/Zj+6O11YR6L4JX0J2sVeBC9Ft7x7HFN7xk7yUV/zDxqV6rjvqgl6r8Dq7oQImxtyf/Mzcw==} + peerDependencies: + '@types/react': ^16.0.0 + '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -3143,6 +3355,9 @@ packages: '@types/react-window@1.8.8': resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==} + '@types/react@16.14.65': + resolution: {integrity: sha512-Guc3kE+W8LrQB9I3bF3blvNH15dXFIVIHIJTqrF8cp5XI/3IJcHGo4C3sJNPb8Zx49aofXKnAGIKyonE4f7XWg==} + '@types/react@18.3.21': resolution: {integrity: sha512-gXLBtmlcRJeT09/sI4PxVwyrku6SaNUj/6cMubjE6T6XdY1fDmBL7r0nX0jbSZPU/Xr0KuwLLZh6aOYY5d91Xw==} @@ -3151,6 +3366,9 @@ packages: peerDependencies: '@types/react': '*' + '@types/scheduler@0.16.8': + resolution: {integrity: sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==} + '@types/sinonjs__fake-timers@8.1.1': resolution: {integrity: sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==} @@ -3700,6 +3918,9 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + big.js@7.0.1: + resolution: {integrity: sha512-iFgV784tD8kq4ccF1xtNMZnXeZzVuXWWM+ERFzKQjv+A5G9HC8CY3DuV45vgzFFcW+u2tIvmF95+AzWgs6BjCg==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -3843,10 +4064,6 @@ packages: cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} - cheerio@1.0.0-rc.11: - resolution: {integrity: sha512-bQwNaDIBKID5ts/DsdhxrjqFXYfLw4ste+wMKqWA8DyKcS4qwsPP4Bk8ZNaTJjvpiX/qW3BT4sU7d6Bh5i+dag==} - engines: {node: '>= 6'} - cheerio@1.0.0-rc.12: resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==} engines: {node: '>= 6'} @@ -4319,6 +4536,9 @@ packages: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -4517,6 +4737,9 @@ packages: resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} engines: {node: '>=12'} + downloadjs@1.4.7: + resolution: {integrity: sha512-LN1gO7+u9xjU5oEScGFKvXhYf7Y/empUIIEAGBs1LzUq/rg5duiDrkuH5A2lQGd5jfMOb9X9usDa2oVXwJ0U/Q==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -5914,10 +6137,6 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - link-preview-js@3.0.14: - resolution: {integrity: sha512-BAGZGCogqsWfF3msPt0c6DXr4+4zv7fregAxPioFYZJKoQEbKhJOhmu7VQjZmtKd1VRQ6CbL80Ok2KhpIuWJnQ==} - engines: {node: '>=18'} - listr2@3.14.0: resolution: {integrity: sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==} engines: {node: '>=10.0.0'} @@ -6804,9 +7023,6 @@ packages: pump@3.0.2: resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} - punycode@1.3.2: - resolution: {integrity: sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -6821,11 +7037,6 @@ packages: quansync@0.2.10: resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==} - querystring@0.2.0: - resolution: {integrity: sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==} - engines: {node: '>=0.4.x'} - deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead. - querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -6891,6 +7102,12 @@ packages: react: ^16.9.0 || ^17 || ^18 react-dom: ^16.9.0 || ^17 || ^18 + react-day-picker@8.10.1: + resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==} + peerDependencies: + date-fns: ^2.28.0 || ^3.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -6914,6 +7131,19 @@ packages: react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + react-gesture-responder@2.1.0: + resolution: {integrity: sha512-uXfFNOtSus5zo2u2WoXMmfAWLdbAYvubmA4AOk9UyLO9n7koVagvSHW7MOUEnhE+F6NeVuyTc+8pyZxLKwyxmg==} + peerDependencies: + react: ^16.8.6 + react-dom: ^16.8.6 + + react-grid-dnd@2.1.2: + resolution: {integrity: sha512-E+XcyemjmRm5Dk3Rn4e2KPpRK4dF1e8NyuOXhm4ZsmVIJVnkP+AzojkTn333ec2Fbe55PUxuHqYA6Fl0Wz65Ng==} + peerDependencies: + react: ^16.8.6 + react-dom: ^16.8.6 + react-gesture-responder: ^2.1.0 + react-helmet@6.1.0: resolution: {integrity: sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==} peerDependencies: @@ -7076,6 +7306,12 @@ packages: peerDependencies: react: ^16.3.0 || ^17.0.0 || ^18.0.0 + react-spring@9.0.0-beta.8: + resolution: {integrity: sha512-pcyQqr5W9HBM5Rm0rmdbVIEQz7wocAfWBNWSUm/EEvpb4M0OQ3Xp9JW6Ayo+6ZgV7fLzJJHO/QgNdUMpAtVaTw==} + peerDependencies: + react: '>= 16.8.0' + react-dom: '>= 16.8.0' + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -7935,6 +8171,9 @@ packages: tsconfig@7.0.0: resolution: {integrity: sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -8095,9 +8334,6 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - url@0.11.0: - resolution: {integrity: sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==} - use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -8568,6 +8804,16 @@ snapshots: react: 18.3.1 tslib: 2.8.1 + '@atlaskit/analytics-next@11.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@atlaskit/analytics-next-stable-react-context': 1.0.1(react@18.3.1) + '@atlaskit/platform-feature-flags': 1.1.1(react@18.3.1) + '@babel/runtime': 7.27.1 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + use-memo-one: 1.1.3(react@18.3.1) + '@atlaskit/analytics-next@9.3.4(react@18.3.1)': dependencies: '@atlaskit/analytics-next-stable-react-context': 1.0.1(react@18.3.1) @@ -8588,6 +8834,17 @@ snapshots: - '@types/react' - supports-color + '@atlaskit/app-provider@2.2.0(@types/react@18.3.21)(react@18.3.1)': + dependencies: + '@atlaskit/platform-feature-flags': 1.1.1(react@18.3.1) + '@atlaskit/tokens': 4.9.1(@types/react@18.3.21)(react@18.3.1) + '@babel/runtime': 7.27.1 + bind-event-listener: 3.0.0 + react: 18.3.1 + transitivePeerDependencies: + - '@types/react' + - supports-color + '@atlaskit/atlassian-context@0.2.0(react@18.3.1)': dependencies: '@babel/runtime': 7.27.1 @@ -8603,6 +8860,16 @@ snapshots: - '@types/react' - supports-color + '@atlaskit/css@0.10.6(@types/react@18.3.21)(react@18.3.1)': + dependencies: + '@atlaskit/tokens': 4.9.1(@types/react@18.3.21)(react@18.3.1) + '@babel/runtime': 7.27.1 + '@compiled/react': 0.18.4(react@18.3.1) + react: 18.3.1 + transitivePeerDependencies: + - '@types/react' + - supports-color + '@atlaskit/ds-lib@2.7.0(@types/react@18.3.21)(react@18.3.1)': dependencies: '@atlaskit/platform-feature-flags': 0.3.0 @@ -8623,6 +8890,16 @@ snapshots: transitivePeerDependencies: - '@types/react' + '@atlaskit/ds-lib@4.0.0(@types/react@18.3.21)(react@18.3.1)': + dependencies: + '@atlaskit/platform-feature-flags': 1.1.1(react@18.3.1) + '@babel/runtime': 7.27.1 + bind-event-listener: 3.0.0 + react: 18.3.1 + react-uid: 2.4.0(@types/react@18.3.21)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + '@atlaskit/feature-gate-js-client@5.3.0(react@18.3.1)': dependencies: '@atlaskit/atlassian-context': 0.2.0(react@18.3.1) @@ -8633,11 +8910,52 @@ snapshots: transitivePeerDependencies: - react + '@atlaskit/focus-ring@3.0.0(@types/react@18.3.21)(react@18.3.1)': + dependencies: + '@atlaskit/tokens': 4.9.1(@types/react@18.3.21)(react@18.3.1) + '@babel/runtime': 7.27.1 + '@emotion/react': 11.14.0(@types/react@18.3.21)(react@18.3.1) + react: 18.3.1 + transitivePeerDependencies: + - '@types/react' + - supports-color + + '@atlaskit/icon@25.8.0(@babel/core@7.27.1)(@types/react@18.3.21)(react@18.3.1)': + dependencies: + '@atlaskit/platform-feature-flags': 1.1.1(react@18.3.1) + '@atlaskit/tokens': 4.9.1(@types/react@18.3.21)(react@18.3.1) + '@babel/register': 7.27.1(@babel/core@7.27.1) + '@babel/runtime': 7.27.1 + '@emotion/react': 11.14.0(@types/react@18.3.21)(react@18.3.1) + react: 18.3.1 + transitivePeerDependencies: + - '@babel/core' + - '@types/react' + - supports-color + '@atlaskit/interaction-context@2.6.0(react@18.3.1)': dependencies: '@babel/runtime': 7.27.1 react: 18.3.1 + '@atlaskit/interaction-context@3.0.0(react@18.3.1)': + dependencies: + '@babel/runtime': 7.27.1 + react: 18.3.1 + + '@atlaskit/motion@5.1.3(@types/react@18.3.21)(react@18.3.1)': + dependencies: + '@atlaskit/css': 0.10.6(@types/react@18.3.21)(react@18.3.1) + '@atlaskit/ds-lib': 4.0.0(@types/react@18.3.21)(react@18.3.1) + '@atlaskit/platform-feature-flags': 1.1.1(react@18.3.1) + '@babel/runtime': 7.27.1 + '@compiled/react': 0.18.4(react@18.3.1) + bind-event-listener: 3.0.0 + react: 18.3.1 + transitivePeerDependencies: + - '@types/react' + - supports-color + '@atlaskit/platform-feature-flags@0.3.0': dependencies: '@babel/runtime': 7.27.1 @@ -8649,6 +8967,85 @@ snapshots: transitivePeerDependencies: - react + '@atlaskit/pragmatic-drag-and-drop-auto-scroll@2.1.0': + dependencies: + '@atlaskit/pragmatic-drag-and-drop': 1.5.2 + '@babel/runtime': 7.27.1 + + '@atlaskit/pragmatic-drag-and-drop-flourish@2.0.2(@types/react@18.3.21)(react@18.3.1)': + dependencies: + '@atlaskit/motion': 5.1.3(@types/react@18.3.21)(react@18.3.1) + '@atlaskit/tokens': 4.9.1(@types/react@18.3.21)(react@18.3.1) + '@babel/runtime': 7.27.1 + transitivePeerDependencies: + - '@types/react' + - react + - supports-color + + '@atlaskit/pragmatic-drag-and-drop-hitbox@1.0.3': + dependencies: + '@atlaskit/pragmatic-drag-and-drop': 1.5.2 + '@babel/runtime': 7.27.1 + + '@atlaskit/pragmatic-drag-and-drop-live-region@1.3.0': + dependencies: + '@babel/runtime': 7.27.1 + + '@atlaskit/pragmatic-drag-and-drop-react-accessibility@2.0.3(@babel/core@7.27.1)(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@atlaskit/focus-ring': 3.0.0(@types/react@18.3.21)(react@18.3.1) + '@atlaskit/icon': 25.8.0(@babel/core@7.27.1)(@types/react@18.3.21)(react@18.3.1) + '@atlaskit/platform-feature-flags': 1.1.1(react@18.3.1) + '@atlaskit/primitives': 14.8.2(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@atlaskit/tokens': 4.9.1(@types/react@18.3.21)(react@18.3.1) + '@babel/runtime': 7.27.1 + '@emotion/react': 11.14.0(@types/react@18.3.21)(react@18.3.1) + react: 18.3.1 + transitivePeerDependencies: + - '@babel/core' + - '@types/react' + - react-dom + - supports-color + + '@atlaskit/pragmatic-drag-and-drop-react-drop-indicator@3.1.0(@types/react@18.3.21)(react@18.3.1)': + dependencies: + '@atlaskit/pragmatic-drag-and-drop': 1.5.2 + '@atlaskit/pragmatic-drag-and-drop-hitbox': 1.0.3 + '@atlaskit/tokens': 4.9.1(@types/react@18.3.21)(react@18.3.1) + '@babel/runtime': 7.27.1 + '@compiled/react': 0.18.4(react@18.3.1) + react: 18.3.1 + transitivePeerDependencies: + - '@types/react' + - supports-color + + '@atlaskit/pragmatic-drag-and-drop@1.5.2': + dependencies: + '@babel/runtime': 7.27.1 + bind-event-listener: 3.0.0 + raf-schd: 4.0.3 + + '@atlaskit/primitives@14.8.2(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@atlaskit/analytics-next': 11.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@atlaskit/app-provider': 2.2.0(@types/react@18.3.21)(react@18.3.1) + '@atlaskit/css': 0.10.6(@types/react@18.3.21)(react@18.3.1) + '@atlaskit/ds-lib': 4.0.0(@types/react@18.3.21)(react@18.3.1) + '@atlaskit/interaction-context': 3.0.0(react@18.3.1) + '@atlaskit/tokens': 4.9.1(@types/react@18.3.21)(react@18.3.1) + '@atlaskit/visually-hidden': 3.0.3(react@18.3.1) + '@babel/runtime': 7.27.1 + '@compiled/react': 0.18.4(react@18.3.1) + '@emotion/react': 11.14.0(@types/react@18.3.21)(react@18.3.1) + '@emotion/serialize': 1.3.3 + bind-event-listener: 3.0.0 + react: 18.3.1 + tiny-invariant: 1.3.3 + transitivePeerDependencies: + - '@types/react' + - react-dom + - supports-color + '@atlaskit/primitives@5.7.0(@types/react@18.3.21)(react@18.3.1)': dependencies: '@atlaskit/analytics-next': 9.3.4(react@18.3.1) @@ -8694,6 +9091,19 @@ snapshots: - '@types/react' - supports-color + '@atlaskit/tokens@4.9.1(@types/react@18.3.21)(react@18.3.1)': + dependencies: + '@atlaskit/ds-lib': 4.0.0(@types/react@18.3.21)(react@18.3.1) + '@atlaskit/platform-feature-flags': 1.1.1(react@18.3.1) + '@babel/runtime': 7.27.1 + '@babel/traverse': 7.27.1 + '@babel/types': 7.27.1 + bind-event-listener: 3.0.0 + react: 18.3.1 + transitivePeerDependencies: + - '@types/react' + - supports-color + '@atlaskit/visually-hidden@1.6.0(@types/react@18.3.21)(react@18.3.1)': dependencies: '@babel/runtime': 7.27.1 @@ -8703,6 +9113,12 @@ snapshots: - '@types/react' - supports-color + '@atlaskit/visually-hidden@3.0.3(react@18.3.1)': + dependencies: + '@babel/runtime': 7.27.1 + '@compiled/react': 0.18.4(react@18.3.1) + react: 18.3.1 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -9535,6 +9951,11 @@ snapshots: csstype: 3.1.3 react: 18.3.1 + '@compiled/react@0.18.4(react@18.3.1)': + dependencies: + csstype: 3.1.3 + react: 18.3.1 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -10265,14 +10686,14 @@ snapshots: optionalDependencies: '@types/react': 18.3.21 - '@mui/x-date-pickers-pro@6.20.2(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@mui/material@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(date-fns@2.30.0)(dayjs@1.11.13)(luxon@3.6.1)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mui/x-date-pickers-pro@6.20.2(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@mui/material@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(date-fns@4.1.0)(dayjs@1.11.13)(luxon@3.6.1)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.27.1 '@mui/base': 5.0.0-beta.70(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mui/material': 6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mui/system': 6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1) '@mui/utils': 5.17.1(@types/react@18.3.21)(react@18.3.1) - '@mui/x-date-pickers': 6.20.2(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@mui/material@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(date-fns@2.30.0)(dayjs@1.11.13)(luxon@3.6.1)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mui/x-date-pickers': 6.20.2(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@mui/material@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(date-fns@4.1.0)(dayjs@1.11.13)(luxon@3.6.1)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mui/x-license-pro': 6.10.2(@types/react@18.3.21)(react@18.3.1) clsx: 2.1.1 prop-types: 15.8.1 @@ -10282,14 +10703,14 @@ snapshots: optionalDependencies: '@emotion/react': 11.14.0(@types/react@18.3.21)(react@18.3.1) '@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1) - date-fns: 2.30.0 + date-fns: 4.1.0 dayjs: 1.11.13 luxon: 3.6.1 moment: 2.30.1 transitivePeerDependencies: - '@types/react' - '@mui/x-date-pickers@6.20.2(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@mui/material@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(date-fns@2.30.0)(dayjs@1.11.13)(luxon@3.6.1)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mui/x-date-pickers@6.20.2(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@mui/material@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(date-fns@4.1.0)(dayjs@1.11.13)(luxon@3.6.1)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.27.1 '@mui/base': 5.0.0-beta.70(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -10305,7 +10726,7 @@ snapshots: optionalDependencies: '@emotion/react': 11.14.0(@types/react@18.3.21)(react@18.3.1) '@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1) - date-fns: 2.30.0 + date-fns: 4.1.0 dayjs: 1.11.13 luxon: 3.6.1 moment: 2.30.1 @@ -10447,6 +10868,18 @@ snapshots: '@types/react': 18.3.21 '@types/react-dom': 18.3.7(@types/react@18.3.21) + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.21)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.21)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.21)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.21 + '@types/react-dom': 18.3.7(@types/react@18.3.21) + '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.21)(react@18.3.1)': dependencies: react: 18.3.1 @@ -10644,6 +11077,15 @@ snapshots: '@types/react': 18.3.21 '@types/react-dom': 18.3.7(@types/react@18.3.21) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.21)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.21 + '@types/react-dom': 18.3.7(@types/react@18.3.21) + '@radix-ui/react-radio-group@1.3.6(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -10662,6 +11104,23 @@ snapshots: '@types/react': 18.3.21 '@types/react-dom': 18.3.7(@types/react@18.3.21) + '@radix-ui/react-roving-focus@1.1.10(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.21)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.21)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.21)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.21)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.21)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.21)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.21 + '@types/react-dom': 18.3.7(@types/react@18.3.21) + '@radix-ui/react-roving-focus@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -10724,6 +11183,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.21 + '@radix-ui/react-slot@1.2.3(@types/react@18.3.21)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.21)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.21 + '@radix-ui/react-switch@1.2.4(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -10739,6 +11205,22 @@ snapshots: '@types/react': 18.3.21 '@types/react-dom': 18.3.7(@types/react@18.3.21) + '@radix-ui/react-tabs@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.21)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.21)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.21)(react@18.3.1) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.21)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.21 + '@types/react-dom': 18.3.7(@types/react@18.3.21) + '@radix-ui/react-toast@1.2.13(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -11052,6 +11534,14 @@ snapshots: transitivePeerDependencies: - typescript + '@tanstack/react-virtual@3.13.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/virtual-core': 3.13.9 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@tanstack/virtual-core@3.13.9': {} + '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.27.1 @@ -11115,6 +11605,8 @@ snapshots: dependencies: '@babel/types': 7.27.1 + '@types/big.js@6.2.2': {} + '@types/cypress-image-snapshot@3.1.9': {} '@types/d3-array@3.2.1': {} @@ -11244,6 +11736,8 @@ snapshots: dependencies: dompurify: 3.2.5 + '@types/downloadjs@1.4.6': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -11362,6 +11856,10 @@ snapshots: - react - react-dom + '@types/react-dom@16.9.25(@types/react@16.14.65)': + dependencies: + '@types/react': 16.14.65 + '@types/react-dom@18.3.7(@types/react@18.3.21)': dependencies: '@types/react': 18.3.21 @@ -11397,6 +11895,12 @@ snapshots: dependencies: '@types/react': 18.3.21 + '@types/react@16.14.65': + dependencies: + '@types/prop-types': 15.7.14 + '@types/scheduler': 0.16.8 + csstype: 3.1.3 + '@types/react@18.3.21': dependencies: '@types/prop-types': 15.7.14 @@ -11406,6 +11910,8 @@ snapshots: dependencies: '@types/react': 18.3.21 + '@types/scheduler@0.16.8': {} + '@types/sinonjs__fake-timers@8.1.1': {} '@types/sizzle@2.3.9': {} @@ -12022,6 +12528,8 @@ snapshots: dependencies: tweetnacl: 0.14.5 + big.js@7.0.1: {} + binary-extensions@2.3.0: {} bind-event-listener@3.0.0: {} @@ -12182,17 +12690,6 @@ snapshots: domhandler: 5.0.3 domutils: 3.2.2 - cheerio@1.0.0-rc.11: - dependencies: - cheerio-select: 2.1.0 - dom-serializer: 2.0.0 - domhandler: 5.0.3 - domutils: 3.2.2 - htmlparser2: 8.0.2 - parse5: 7.3.0 - parse5-htmlparser2-tree-adapter: 7.1.0 - tslib: 2.8.1 - cheerio@1.0.0-rc.12: dependencies: cheerio-select: 2.1.0 @@ -12769,6 +13266,8 @@ snapshots: dependencies: '@babel/runtime': 7.27.1 + date-fns@4.1.0: {} + dateformat@4.6.3: {} dayjs@1.11.13: {} @@ -12947,6 +13446,8 @@ snapshots: dotenv@16.5.0: {} + downloadjs@1.4.7: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -14763,11 +15264,6 @@ snapshots: lines-and-columns@1.2.4: {} - link-preview-js@3.0.14: - dependencies: - cheerio: 1.0.0-rc.11 - url: 0.11.0 - listr2@3.14.0(enquirer@2.4.1): dependencies: cli-truncate: 2.1.0 @@ -15821,8 +16317,6 @@ snapshots: end-of-stream: 1.4.4 once: 1.4.0 - punycode@1.3.2: {} - punycode@2.3.1: {} pure-rand@6.1.0: {} @@ -15833,8 +16327,6 @@ snapshots: quansync@0.2.10: {} - querystring@0.2.0: {} - querystringify@2.2.0: {} queue-microtask@1.2.3: {} @@ -15945,6 +16437,11 @@ snapshots: react-onclickoutside: 6.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-day-picker@8.10.1(date-fns@4.1.0)(react@18.3.1): + dependencies: + date-fns: 4.1.0 + react: 18.3.1 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -15970,6 +16467,25 @@ snapshots: react-fast-compare@3.2.2: {} + react-gesture-responder@2.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@types/react': 16.14.65 + '@types/react-dom': 16.9.25(@types/react@16.14.65) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 1.14.1 + + react-grid-dnd@2.1.2(react-dom@18.3.1(react@18.3.1))(react-gesture-responder@2.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + dependencies: + '@types/react': 16.14.65 + '@types/react-dom': 16.9.25(@types/react@16.14.65) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-gesture-responder: 2.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-spring: 9.0.0-beta.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + resize-observer-polyfill: 1.5.1 + tslib: 1.14.1 + react-helmet@6.1.0(react@18.3.1): dependencies: object-assign: 4.1.1 @@ -16127,6 +16643,13 @@ snapshots: dependencies: react: 18.3.1 + react-spring@9.0.0-beta.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.27.1 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-style-singleton@2.2.3(@types/react@18.3.21)(react@18.3.1): dependencies: get-nonce: 1.0.1 @@ -17111,6 +17634,8 @@ snapshots: strip-bom: 3.0.0 strip-json-comments: 2.0.1 + tslib@1.14.1: {} + tslib@2.8.1: {} tunnel-agent@0.6.0: @@ -17301,11 +17826,6 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 - url@0.11.0: - dependencies: - punycode: 1.3.2 - querystring: 0.2.0 - use-callback-ref@1.3.3(@types/react@18.3.21)(react@18.3.1): dependencies: react: 18.3.1 diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index 0903aa25..3e9756b0 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -310,7 +310,21 @@ "viewDataBase": "View database", "referencePage": "This {{name}} is referenced", "addBlockBelow": "Add a block below", - "aiGenerate": "Generate" + "aiGenerate": "Generate", + "textField": "Add text that will be used to generate the content", + "numberField": "Accepts numbers. These can be formatted as currency, percentages, or decimals", + "dateField": "Accepts a date or a date range(time optional). Useful for deadlines, events, and scheduling", + "singleSelectField": "Select one option from a list of tags. Useful for categorizing", + "multiSelectField": "Select one or more options from a list of tags. Useful for tagging items across multiple categories", + "checkboxField": "Use a checkbox to indicate yes/no. Useful for tracking tasks", + "checklistField": "Add a checklist for to-do items. Useful for tracking tasks and progress", + "urlField": "Accepts a URL. Useful for linking to external resources", + "updatedAtField": "Records the timestamp of the last update. Useful for tracking changes", + "createdAtField": "Records the timestamp of when the item was created. Useful for tracking history", + "relationField": "Relate to another database. Useful for linking items across databases", + "AISummaryField": "Summarize the content of the record. Useful for getting a quick overview", + "AITranslateField": "Translate the content of the record. Useful for multilingual support", + "mediaField": "Add images, videos, or other media. Useful for rich content" }, "sideBar": { "closeSidebar": "Close sidebar", @@ -438,7 +452,9 @@ "retry": "Retry", "uploadFailed": "Upload failed.", "copyLinkOriginal": "Copy link to original", - "search": "Search" + "search": "Search", + "checked": "Checked", + "unchecked": "Unchecked" }, "label": { "welcome": "Welcome!", @@ -1501,21 +1517,20 @@ "newColumn": "New column", "format": "Format", "reminderOnDateTooltip": "This cell has a scheduled reminder", - "optionAlreadyExist": "Option already exists" + "optionAlreadyExist": "Option already exists", + "copiedCreatedAt": "Created at copied to clipboard", + "copiedUpdatedAt": "Last modified copied to clipboard", + "copiedDate": "Date copied to clipboard" }, "rowPage": { "newField": "Add a new field", "fieldDragElementTooltip": "Click to open menu", - "showHiddenFields": { - "one": "Show {{count}} hidden field", - "many": "Show {{count}} hidden fields", - "other": "Show {{count}} hidden fields" - }, - "hideHiddenFields": { - "one": "Hide {{count}} hidden field", - "many": "Hide {{count}} hidden fields", - "other": "Hide {{count}} hidden fields" - }, + "showHiddenFields_one": "Show {{count}} hidden field", + "showHiddenFields_many": "Show {{count}} hidden fields", + "showHiddenFields_other": "Show {{count}} hidden fields", + "hideHiddenFields_one": "Hide {{count}} hidden field", + "hideHiddenFields_many": "Hide {{count}} hidden fields", + "hideHiddenFields_other": "Hide {{count}} hidden fields", "openAsFullPage": "Open as full page", "moreRowActions": "More row actions" }, @@ -1544,7 +1559,7 @@ "action": "Action", "add": "Click add to below", "drag": "Drag to move", - "deleteRowPrompt": "Are you sure you want to delete this row? This action cannot be undone.", + "deleteRowPrompt": "Are you sure you want to delete {{count}} rows? This action cannot be undone.", "deleteCardPrompt": "Are you sure you want to delete this card? This action cannot be undone.", "dragAndClick": "Drag to move, click to open menu", "insertRecordAbove": "Insert record above", @@ -1596,7 +1611,9 @@ "noDatabaseSelected": "No database selected, please select one first from the list below:", "emptySearchResult": "No records found", "linkedRowListLabel": "{{count}} linked rows", - "unlinkedRowListLabel": "Link another row" + "unlinkedRowListLabel": "Link another row", + "removeRelation": "Remove relation", + "addRelation": "Add relation" }, "menuName": "Grid", "referencedGridPrefix": "View of", @@ -1612,25 +1629,40 @@ "countEmpty": "Count empty", "countEmptyShort": "EMPTY", "countNonEmpty": "Count not empty", - "countNonEmptyShort": "FILLED" + "countNonEmptyShort": "FILLED", + "countChecked": "Count checked", + "countCheckedShort": "CHECKED", + "countUnchecked": "Count unchecked", + "countUncheckedShort": "UNCHECKED", + "countUncompleted": "Count uncompleted", + "countUncompletedShort": "UNCOMPLETED", + "countCompleted": "Count completed", + "countCompletedShort": "COMPLETED" }, "media": { - "rename": "Rename", + "rename": "Rename file", "download": "Download", "expand": "Expand", - "delete": "Delete", + "delete": "Delete file", "moreFilesHint": "+{}", "addFileOrImage": "Add file or link", "attachmentsHint": "{}", + "renameHint": "Enter the new for this file", "addFileMobile": "Add file", "extraCount": "+{}", - "deleteFileDescription": "Are you sure you want to delete this file? This action is irreversible.", + "deleteFileDescription": "Delete {{name}}? This action cannot be undone.", "showFileNames": "Show file name", "downloadSuccess": "File downloaded", "downloadFailedToken": "Failed to download file, user token unavailable", "setAsCover": "Set as cover", "openInBrowser": "Open in browser", - "embedLink": "Embed file link" + "embedLink": "Embed link", + "upload": "Upload", + "dragAndDropFiles": "Drag and drop a file or ", + "browse": "Browse", + "networkHint": "Paste a link to embed", + "uploadError": "Upload failed, please try again", + "addFileOrMedia": "Add file or media" } }, "document": { @@ -2067,6 +2099,8 @@ "hideColumn": "Hide", "newGroup": "New group", "deleteColumn": "Delete", + "deleteCards": "This will delete all the cards in this group. Are you sure you want to continue?", + "deleteOptionAndCards": "This will delete this option and all the cards in it. Are you sure you want to continue?", "deleteColumnConfirmation": "This will delete this group and all the cards in it. Are you sure you want to continue?" }, "hiddenGroupSection": { diff --git a/src/application/database-yjs/__tests__/filter.test.ts b/src/application/database-yjs/__tests__/filter.test.ts deleted file mode 100644 index b07dc9be..00000000 --- a/src/application/database-yjs/__tests__/filter.test.ts +++ /dev/null @@ -1,672 +0,0 @@ -import { - NumberFilterCondition, - TextFilterCondition, - CheckboxFilterCondition, - ChecklistFilterCondition, - SelectOptionFilterCondition, - Row, -} from '@/application/database-yjs'; -import { withTestingData } from '@/application/database-yjs/__tests__/withTestingData'; -import { - withCheckboxFilter, - withChecklistFilter, - withDateTimeFilter, - withMultiSelectOptionFilter, - withNumberFilter, - withRichTextFilter, - withSingleSelectOptionFilter, - withUrlFilter, -} from '@/application/database-yjs/__tests__/withTestingFilters'; -import { withTestingRows } from '@/application/database-yjs/__tests__/withTestingRows'; -import { RowId, YDoc } from '@/application/types'; -import { - textFilterCheck, - numberFilterCheck, - checkboxFilterCheck, - checklistFilterCheck, - selectOptionFilterCheck, - filterBy, -} from '../filter'; -import { expect } from '@jest/globals'; -import * as Y from 'yjs'; - -describe('Text filter check', () => { - const text = 'Hello, world!'; - it('should return true for TextIs condition', () => { - const condition = TextFilterCondition.TextIs; - const content = 'Hello, world!'; - - const result = textFilterCheck(text, content, condition); - - expect(result).toBe(true); - }); - - it('should return false for TextIs condition', () => { - const condition = TextFilterCondition.TextIs; - const content = 'Hello, world'; - - const result = textFilterCheck(text, content, condition); - - expect(result).toBe(false); - }); - - it('should return true for TextIsNot condition', () => { - const condition = TextFilterCondition.TextIsNot; - const content = 'Hello, world'; - - const result = textFilterCheck(text, content, condition); - - expect(result).toBe(true); - }); - - it('should return false for TextIsNot condition', () => { - const condition = TextFilterCondition.TextIsNot; - const content = 'Hello, world!'; - - const result = textFilterCheck(text, content, condition); - - expect(result).toBe(false); - }); - - it('should return true for TextContains condition', () => { - const condition = TextFilterCondition.TextContains; - const content = 'world'; - - const result = textFilterCheck(text, content, condition); - - expect(result).toBe(true); - }); - - it('should return false for TextContains condition', () => { - const condition = TextFilterCondition.TextContains; - const content = 'planet'; - - const result = textFilterCheck(text, content, condition); - - expect(result).toBe(false); - }); - - it('should return true for TextDoesNotContain condition', () => { - const condition = TextFilterCondition.TextDoesNotContain; - const content = 'planet'; - - const result = textFilterCheck(text, content, condition); - - expect(result).toBe(true); - }); - - it('should return false for TextDoesNotContain condition', () => { - const condition = TextFilterCondition.TextDoesNotContain; - const content = 'world'; - - const result = textFilterCheck(text, content, condition); - - expect(result).toBe(false); - }); - - it('should return true for TextIsEmpty condition', () => { - const condition = TextFilterCondition.TextIsEmpty; - const text = ''; - - const result = textFilterCheck(text, '', condition); - - expect(result).toBe(true); - }); - - it('should return false for TextIsEmpty condition', () => { - const condition = TextFilterCondition.TextIsEmpty; - const text = 'Hello, world!'; - - const result = textFilterCheck(text, '', condition); - - expect(result).toBe(false); - }); - - it('should return true for TextIsNotEmpty condition', () => { - const condition = TextFilterCondition.TextIsNotEmpty; - const text = 'Hello, world!'; - - const result = textFilterCheck(text, '', condition); - - expect(result).toBe(true); - }); - - it('should return false for TextIsNotEmpty condition', () => { - const condition = TextFilterCondition.TextIsNotEmpty; - const text = ''; - - const result = textFilterCheck(text, '', condition); - - expect(result).toBe(false); - }); - - it('should return false for unknown condition', () => { - const condition = 42; - const content = 'Hello, world!'; - - const result = textFilterCheck(text, content, condition); - - expect(result).toBe(false); - }); -}); - -describe('Number filter check', () => { - const num = '42'; - it('should return true for Equal condition', () => { - const condition = NumberFilterCondition.Equal; - const content = '42'; - - const result = numberFilterCheck(num, content, condition); - - expect(result).toBe(true); - }); - - it('should return false for Equal condition', () => { - const condition = NumberFilterCondition.Equal; - const content = '43'; - - const result = numberFilterCheck(num, content, condition); - - expect(result).toBe(false); - }); - - it('should return true for NotEqual condition', () => { - const condition = NumberFilterCondition.NotEqual; - const content = '43'; - - const result = numberFilterCheck(num, content, condition); - - expect(result).toBe(true); - }); - - it('should return false for NotEqual condition', () => { - const condition = NumberFilterCondition.NotEqual; - const content = '42'; - - const result = numberFilterCheck(num, content, condition); - - expect(result).toBe(false); - }); - - it('should return true for GreaterThan condition', () => { - const condition = NumberFilterCondition.GreaterThan; - const content = '41'; - - const result = numberFilterCheck(num, content, condition); - - expect(result).toBe(true); - }); - - it('should return false for GreaterThan condition', () => { - const condition = NumberFilterCondition.GreaterThan; - const content = '42'; - - const result = numberFilterCheck(num, content, condition); - - expect(result).toBe(false); - }); - - it('should return true for GreaterThanOrEqualTo condition', () => { - const condition = NumberFilterCondition.GreaterThanOrEqualTo; - const content = '42'; - - const result = numberFilterCheck(num, content, condition); - - expect(result).toBe(true); - }); - - it('should return false for GreaterThanOrEqualTo condition', () => { - const condition = NumberFilterCondition.GreaterThanOrEqualTo; - const content = '43'; - - const result = numberFilterCheck(num, content, condition); - - expect(result).toBe(false); - }); - - it('should return true for LessThan condition', () => { - const condition = NumberFilterCondition.LessThan; - const content = '43'; - - const result = numberFilterCheck(num, content, condition); - - expect(result).toBe(true); - }); - - it('should return false for LessThan condition', () => { - const condition = NumberFilterCondition.LessThan; - const content = '42'; - - const result = numberFilterCheck(num, content, condition); - - expect(result).toBe(false); - }); - - it('should return true for LessThanOrEqualTo condition', () => { - const condition = NumberFilterCondition.LessThanOrEqualTo; - const content = '42'; - - const result = numberFilterCheck(num, content, condition); - - expect(result).toBe(true); - }); - - it('should return false for LessThanOrEqualTo condition', () => { - const condition = NumberFilterCondition.LessThanOrEqualTo; - const content = '41'; - - const result = numberFilterCheck(num, content, condition); - - expect(result).toBe(false); - }); - - it('should return true for NumberIsEmpty condition', () => { - const condition = NumberFilterCondition.NumberIsEmpty; - - const result = numberFilterCheck('', '', condition); - - expect(result).toBe(true); - }); - - it('should return false for NumberIsEmpty condition', () => { - const condition = NumberFilterCondition.NumberIsEmpty; - const num = '42'; - - const result = numberFilterCheck(num, '', condition); - - expect(result).toBe(false); - }); - - it('should return true for NumberIsNotEmpty condition', () => { - const condition = NumberFilterCondition.NumberIsNotEmpty; - const num = '42'; - - const result = numberFilterCheck(num, '', condition); - - expect(result).toBe(true); - }); - - it('should return false for NumberIsNotEmpty condition', () => { - const condition = NumberFilterCondition.NumberIsNotEmpty; - const num = ''; - - const result = numberFilterCheck(num, '', condition); - - expect(result).toBe(false); - }); - - it('should return false for unknown condition', () => { - const condition = 42; - const content = '42'; - - const result = numberFilterCheck(num, content, condition); - - expect(result).toBe(false); - }); -}); - -describe('Checkbox filter check', () => { - it('should return true for IsChecked condition', () => { - const condition = CheckboxFilterCondition.IsChecked; - const data = 'Yes'; - - const result = checkboxFilterCheck(data, condition); - - expect(result).toBe(true); - }); - - it('should return false for IsChecked condition', () => { - const condition = CheckboxFilterCondition.IsChecked; - const data = 'No'; - - const result = checkboxFilterCheck(data, condition); - - expect(result).toBe(false); - }); - - it('should return true for IsUnChecked condition', () => { - const condition = CheckboxFilterCondition.IsUnChecked; - const data = 'No'; - - const result = checkboxFilterCheck(data, condition); - - expect(result).toBe(true); - }); - - it('should return false for IsUnChecked condition', () => { - const condition = CheckboxFilterCondition.IsUnChecked; - const data = 'Yes'; - - const result = checkboxFilterCheck(data, condition); - - expect(result).toBe(false); - }); - - it('should return false for unknown condition', () => { - const condition = 42; - const data = 'Yes'; - - const result = checkboxFilterCheck(data, condition); - - expect(result).toBe(false); - }); -}); - -describe('Checklist filter check', () => { - it('should return true for IsComplete condition', () => { - const condition = ChecklistFilterCondition.IsComplete; - const data = JSON.stringify({ - options: [ - { id: '1', name: 'Option 1' }, - { id: '2', name: 'Option 2' }, - ], - selected_option_ids: ['1', '2'], - }); - - const result = checklistFilterCheck(data, '', condition); - - expect(result).toBe(true); - }); - - it('should return false for IsComplete condition', () => { - const condition = ChecklistFilterCondition.IsComplete; - const data = JSON.stringify({ - options: [ - { id: '1', name: 'Option 1' }, - { id: '2', name: 'Option 2' }, - ], - selected_option_ids: ['1'], - }); - - const result = checklistFilterCheck(data, '', condition); - - expect(result).toBe(false); - }); - - it('should return false for unknown condition', () => { - const condition = 42; - const data = JSON.stringify({ - options: [ - { id: '1', name: 'Option 1' }, - { id: '2', name: 'Option 2' }, - ], - selected_option_ids: ['1', '2'], - }); - - const result = checklistFilterCheck(data, '', condition); - - expect(result).toBe(false); - }); -}); - -describe('SelectOption filter check', () => { - it('should return true for OptionIs condition', () => { - const condition = SelectOptionFilterCondition.OptionIs; - const content = '1'; - const data = '1,2'; - - const result = selectOptionFilterCheck(data, content, condition); - - expect(result).toBe(true); - }); - - it('should return false for OptionIs condition', () => { - const condition = SelectOptionFilterCondition.OptionIs; - const content = '3'; - const data = '1,2'; - - const result = selectOptionFilterCheck(data, content, condition); - - expect(result).toBe(false); - }); - - it('should return true for OptionIsNot condition', () => { - const condition = SelectOptionFilterCondition.OptionIsNot; - const content = '3'; - const data = '1,2'; - - const result = selectOptionFilterCheck(data, content, condition); - - expect(result).toBe(true); - }); - - it('should return false for OptionIsNot condition', () => { - const condition = SelectOptionFilterCondition.OptionIsNot; - const content = '1'; - const data = '1,2'; - - const result = selectOptionFilterCheck(data, content, condition); - - expect(result).toBe(false); - }); - - it('should return true for OptionContains condition', () => { - const condition = SelectOptionFilterCondition.OptionContains; - const content = '1,3'; - const data = '1,2,3'; - - const result = selectOptionFilterCheck(data, content, condition); - - expect(result).toBe(true); - }); - - it('should return false for OptionContains condition', () => { - const condition = SelectOptionFilterCondition.OptionContains; - const content = '4'; - const data = '1,2,3'; - - const result = selectOptionFilterCheck(data, content, condition); - - expect(result).toBe(false); - }); - - it('should return true for OptionDoesNotContain condition', () => { - const condition = SelectOptionFilterCondition.OptionDoesNotContain; - const content = '4,5'; - const data = '1,2,3'; - - const result = selectOptionFilterCheck(data, content, condition); - - expect(result).toBe(true); - }); - - it('should return false for OptionDoesNotContain condition', () => { - const condition = SelectOptionFilterCondition.OptionDoesNotContain; - const content = '1,3'; - const data = '1,2,3'; - - const result = selectOptionFilterCheck(data, content, condition); - - expect(result).toBe(false); - }); - - it('should return true for OptionIsEmpty condition', () => { - const condition = SelectOptionFilterCondition.OptionIsEmpty; - const data = ''; - - const result = selectOptionFilterCheck(data, '', condition); - - expect(result).toBe(true); - }); - - it('should return false for OptionIsEmpty condition', () => { - const condition = SelectOptionFilterCondition.OptionIsEmpty; - const data = '1,2'; - - const result = selectOptionFilterCheck(data, '', condition); - - expect(result).toBe(false); - }); - - it('should return true for OptionIsNotEmpty condition', () => { - const condition = SelectOptionFilterCondition.OptionIsNotEmpty; - const data = '1,2'; - - const result = selectOptionFilterCheck(data, '', condition); - - expect(result).toBe(true); - }); - - it('should return false for OptionIsNotEmpty condition', () => { - const condition = SelectOptionFilterCondition.OptionIsNotEmpty; - const data = ''; - - const result = selectOptionFilterCheck(data, '', condition); - - expect(result).toBe(false); - }); - - it('should return false for unknown condition', () => { - const condition = 42; - const content = '1'; - const data = '1,2'; - - const result = selectOptionFilterCheck(data, content, condition); - - expect(result).toBe(false); - }); -}); - -describe('Database filterBy', () => { - let rows: Row[]; - - beforeEach(() => { - rows = withTestingRows(); - }); - - it('should return all rows for empty filter', () => { - const { filters, fields, rowMap } = withTestingData(); - const result = filterBy(rows, filters, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(result).toBe('1,2,3,4,5,6,7,8,9,10'); - }); - - it('should return all rows for empty rowMap', () => { - const { filters, fields } = withTestingData(); - const rowMap: Record = {}; - const result = filterBy(rows, filters, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(result).toBe('1,2,3,4,5,6,7,8,9,10'); - }); - - it('should return rows that match text filter', () => { - const { filters, fields, rowMap } = withTestingData(); - const filter = withRichTextFilter(); - filters.push([filter]); - const result = filterBy(rows, filters, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(result).toBe('1,5'); - }); - - it('should return rows that match number filter', () => { - const { filters, fields, rowMap } = withTestingData(); - const filter = withNumberFilter(); - filters.push([filter]); - const result = filterBy(rows, filters, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(result).toBe('4,5,6,7,8,9,10'); - }); - - it('should return rows that match checkbox filter', () => { - const { filters, fields, rowMap } = withTestingData(); - const filter = withCheckboxFilter(); - filters.push([filter]); - const result = filterBy(rows, filters, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(result).toBe('2,4,6,8,10'); - }); - - it('should return rows that match checklist filter', () => { - const { filters, fields, rowMap } = withTestingData(); - const filter = withChecklistFilter(); - filters.push([filter]); - const result = filterBy(rows, filters, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(result).toBe('1,2,4,5,6,7,8,10'); - }); - - it('should return rows that match multiple filters', () => { - const { filters, fields, rowMap } = withTestingData(); - const filter1 = withRichTextFilter(); - const filter2 = withNumberFilter(); - filters.push([filter1, filter2]); - const result = filterBy(rows, filters, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(result).toBe('5'); - }); - - it('should return rows that match url filter', () => { - const { filters, fields, rowMap } = withTestingData(); - const filter = withUrlFilter(); - filters.push([filter]); - const result = filterBy(rows, filters, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(result).toBe('4'); - }); - - it('should return rows that match date filter', () => { - const { filters, fields, rowMap } = withTestingData(); - const filter = withDateTimeFilter(); - filters.push([filter]); - const result = filterBy(rows, filters, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(result).toBe('1,2,3,4,5,6,7,8,9,10'); - }); - - it('should return rows that match select option filter', () => { - const { filters, fields, rowMap } = withTestingData(); - const filter = withSingleSelectOptionFilter(); - filters.push([filter]); - const result = filterBy(rows, filters, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(result).toBe('2,5,8'); - }); - - it('should return rows that match multi select option filter', () => { - const { filters, fields, rowMap } = withTestingData(); - const filter = withMultiSelectOptionFilter(); - filters.push([filter]); - const result = filterBy(rows, filters, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(result).toBe('1,2,3,5,6,7,8,9'); - }); - - it('should return rows that match multiple filters', () => { - const { filters, fields, rowMap } = withTestingData(); - const filter1 = withNumberFilter(); - const filter2 = withChecklistFilter(); - filters.push([filter1, filter2]); - const result = filterBy(rows, filters, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(result).toBe('4,5,6,7,8,10'); - }); - - it('should return empty array for all filters', () => { - const { filters, fields, rowMap } = withTestingData(); - const filter1 = withNumberFilter(); - const filter2 = withChecklistFilter(); - const filter3 = withRichTextFilter(); - const filter4 = withCheckboxFilter(); - const filter5 = withSingleSelectOptionFilter(); - const filter6 = withMultiSelectOptionFilter(); - const filter7 = withUrlFilter(); - const filter8 = withDateTimeFilter(); - filters.push([filter1, filter2, filter3, filter4, filter5, filter6, filter7, filter8]); - const result = filterBy(rows, filters, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(result).toBe(''); - }); -}); diff --git a/src/application/database-yjs/__tests__/fixtures/filters.json b/src/application/database-yjs/__tests__/fixtures/filters.json deleted file mode 100644 index eb0688a5..00000000 --- a/src/application/database-yjs/__tests__/fixtures/filters.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "filter_text_field": { - "field_id": "text_field", - "condition": 2, - "content": "w" - }, - "filter_number_field": { - "field_id": "number_field", - "condition": 2, - "content": 1000 - }, - "filter_date_field": { - "field_id": "date_field", - "condition": 1, - "content": 1685798400000 - }, - "filter_checkbox_field": { - "field_id": "checkbox_field", - "condition": 1 - }, - "filter_checklist_field": { - "field_id": "checklist_field", - "condition": 1 - }, - "filter_url_field": { - "field_id": "url_field", - "condition": 0, - "content": "https://example.com/4" - }, - "filter_single_select_field": { - "field_id": "single_select_field", - "condition": 0, - "content": "2" - }, - "filter_multi_select_field": { - "field_id": "multi_select_field", - "condition": 2, - "content": "1,3" - } -} \ No newline at end of file diff --git a/src/application/database-yjs/__tests__/fixtures/rows.json b/src/application/database-yjs/__tests__/fixtures/rows.json deleted file mode 100644 index 989a3355..00000000 --- a/src/application/database-yjs/__tests__/fixtures/rows.json +++ /dev/null @@ -1,412 +0,0 @@ -[ - { - "id": "1", - "cells": { - "text_field": { - "id": "text_field", - "data": "Hello world" - }, - "number_field": { - "id": "number_field", - "data": 123 - }, - "checkbox_field": { - "id": "checkbox_field", - "data": "Yes" - }, - "date_field": { - "id": "date_field", - "data": 1685539200000, - "end_timestamp": 1685625600000, - "include_time": true, - "is_range": false, - "reminder_id": "rem1" - }, - "url_field": { - "id": "url_field", - "data": "https://example.com/1" - }, - "single_select_field": { - "id": "single_select_field", - "data": "1" - }, - "multi_select_field": { - "id": "multi_select_field", - "data": "1,2" - }, - "checklist_field": { - "id": "checklist_field", - "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"1\"]}" - } - } - }, - { - "id": "2", - "cells": { - "text_field": { - "id": "text_field", - "data": "Good morning" - }, - "number_field": { - "id": "number_field", - "data": 456 - }, - "checkbox_field": { - "id": "checkbox_field", - "data": "No" - }, - "date_field": { - "id": "date_field", - "data": 1685625600000, - "end_timestamp": 1685712000000, - "include_time": false, - "is_range": true, - "reminder_id": "rem2" - }, - "url_field": { - "id": "url_field", - "data": "https://example.com/2" - }, - "single_select_field": { - "id": "single_select_field", - "data": "2" - }, - "multi_select_field": { - "id": "multi_select_field", - "data": "2,3" - }, - "checklist_field": { - "id": "checklist_field", - "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"2\"]}" - } - } - }, - { - "id": "3", - "cells": { - "text_field": { - "id": "text_field", - "data": "Good night" - }, - "number_field": { - "id": "number_field", - "data": 789 - }, - "checkbox_field": { - "id": "checkbox_field", - "data": "Yes" - }, - "date_field": { - "id": "date_field", - "data": 1685712000000, - "end_timestamp": 1685798400000, - "include_time": true, - "is_range": false, - "reminder_id": "rem3" - }, - "url_field": { - "id": "url_field", - "data": "https://example.com/3" - }, - "single_select_field": { - "id": "single_select_field", - "data": "3" - }, - "multi_select_field": { - "id": "multi_select_field", - "data": "1,3" - }, - "checklist_field": { - "id": "checklist_field", - "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"1\",\"2\"]}" - } - } - }, - { - "id": "4", - "cells": { - "text_field": { - "id": "text_field", - "data": "Happy day" - }, - "number_field": { - "id": "number_field", - "data": 1011 - }, - "checkbox_field": { - "id": "checkbox_field", - "data": "No" - }, - "date_field": { - "id": "date_field", - "data": 1685798400000, - "end_timestamp": 1685884800000, - "include_time": false, - "is_range": true, - "reminder_id": "rem4" - }, - "url_field": { - "id": "url_field", - "data": "https://example.com/4" - }, - "single_select_field": { - "id": "single_select_field", - "data": "1" - }, - "multi_select_field": { - "id": "multi_select_field", - "data": "2" - }, - "checklist_field": { - "id": "checklist_field", - "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[]}" - } - } - }, - { - "id": "5", - "cells": { - "text_field": { - "id": "text_field", - "data": "Sunny weather" - }, - "number_field": { - "id": "number_field", - "data": 1213 - }, - "checkbox_field": { - "id": "checkbox_field", - "data": "Yes" - }, - "date_field": { - "id": "date_field", - "data": 1685884800000, - "end_timestamp": 1685971200000, - "include_time": true, - "is_range": false, - "reminder_id": "rem5" - }, - "url_field": { - "id": "url_field", - "data": "https://example.com/5" - }, - "single_select_field": { - "id": "single_select_field", - "data": "2" - }, - "multi_select_field": { - "id": "multi_select_field", - "data": "1,2,3" - }, - "checklist_field": { - "id": "checklist_field", - "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"1\"]}" - } - } - }, - { - "id": "6", - "cells": { - "text_field": { - "id": "text_field", - "data": "Rainy day" - }, - "number_field": { - "id": "number_field", - "data": 1415 - }, - "checkbox_field": { - "id": "checkbox_field", - "data": "No" - }, - "date_field": { - "id": "date_field", - "data": 1685971200000, - "end_timestamp": 1686057600000, - "include_time": false, - "is_range": true, - "reminder_id": "rem6" - }, - "url_field": { - "id": "url_field", - "data": "https://example.com/6" - }, - "single_select_field": { - "id": "single_select_field", - "data": "3" - }, - "multi_select_field": { - "id": "multi_select_field", - "data": "1,3" - }, - "checklist_field": { - "id": "checklist_field", - "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"2\"]}" - } - } - }, - { - "id": "7", - "cells": { - "text_field": { - "id": "text_field", - "data": "Winter is coming" - }, - "number_field": { - "id": "number_field", - "data": 1617 - }, - "checkbox_field": { - "id": "checkbox_field", - "data": "Yes" - }, - "date_field": { - "id": "date_field", - "data": 1686057600000, - "end_timestamp": 1686144000000, - "include_time": true, - "is_range": false, - "reminder_id": "rem7" - }, - "url_field": { - "id": "url_field", - "data": "https://example.com/7" - }, - "single_select_field": { - "id": "single_select_field", - "data": "1" - }, - "multi_select_field": { - "id": "multi_select_field", - "data": "1,2" - }, - "checklist_field": { - "id": "checklist_field", - "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"1\"]}" - } - } - }, - { - "id": "8", - "cells": { - "text_field": { - "id": "text_field", - "data": "Summer vibes" - }, - "number_field": { - "id": "number_field", - "data": 1819 - }, - "checkbox_field": { - "id": "checkbox_field", - "data": "No" - }, - "date_field": { - "id": "date_field", - "data": 1686144000000, - "end_timestamp": 1686230400000, - "include_time": false, - "is_range": true, - "reminder_id": "rem8" - }, - "url_field": { - "id": "url_field", - "data": "https://example.com/8" - }, - "single_select_field": { - "id": "single_select_field", - "data": "2" - }, - "multi_select_field": { - "id": "multi_select_field", - "data": "2,3" - }, - "checklist_field": { - "id": "checklist_field", - "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"2\"]}" - } - } - }, - { - "id": "9", - "cells": { - "text_field": { - "id": "text_field", - "data": "Autumn leaves" - }, - "number_field": { - "id": "number_field", - "data": 2021 - }, - "checkbox_field": { - "id": "checkbox_field", - "data": "Yes" - }, - "date_field": { - "id": "date_field", - "data": 1686230400000, - "end_timestamp": 1686316800000, - "include_time": true, - "is_range": false, - "reminder_id": "rem9" - }, - "url_field": { - "id": "url_field", - "data": "https://example.com/9" - }, - "single_select_field": { - "id": "single_select_field", - "data": "3" - }, - "multi_select_field": { - "id": "multi_select_field", - "data": "1,3" - }, - "checklist_field": { - "id": "checklist_field", - "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"1\",\"2\"]}" - } - } - }, - { - "id": "10", - "cells": { - "text_field": { - "id": "text_field", - "data": "Spring blossoms" - }, - "number_field": { - "id": "number_field", - "data": 2223 - }, - "checkbox_field": { - "id": "checkbox_field", - "data": "No" - }, - "date_field": { - "id": "date_field", - "data": 1686316800000, - "end_timestamp": 1686403200000, - "include_time": false, - "is_range": true, - "reminder_id": "rem10" - }, - "url_field": { - "id": "url_field", - "data": "https://example.com/10" - }, - "single_select_field": { - "id": "single_select_field", - "data": "1" - }, - "multi_select_field": { - "id": "multi_select_field", - "data": "2" - }, - "checklist_field": { - "id": "checklist_field", - "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[]}" - } - } - } -] diff --git a/src/application/database-yjs/__tests__/fixtures/sorts.json b/src/application/database-yjs/__tests__/fixtures/sorts.json deleted file mode 100644 index 11ae36cf..00000000 --- a/src/application/database-yjs/__tests__/fixtures/sorts.json +++ /dev/null @@ -1,102 +0,0 @@ -{ - "sort_asc_text_field": { - "id": "sort_asc_text_field", - "field_id": "text_field", - "condition": "asc" - }, - "sort_desc_text_field": { - "field_id": "text_field", - "condition": "desc", - "id": "sort_desc_text_field" - }, - "sort_asc_number_field": { - "field_id": "number_field", - "condition": "asc", - "id": "sort_asc_number_field" - }, - "sort_desc_number_field": { - "field_id": "number_field", - "condition": "desc", - "id": "sort_desc_number_field" - }, - "sort_asc_date_field": { - "field_id": "date_field", - "condition": "asc", - "id": "sort_asc_date_field" - }, - "sort_desc_date_field": { - "field_id": "date_field", - "condition": "desc", - "id": "sort_desc_date_field" - }, - "sort_asc_checkbox_field": { - "field_id": "checkbox_field", - "condition": "asc", - "id": "sort_asc_checkbox_field" - }, - "sort_desc_checkbox_field": { - "field_id": "checkbox_field", - "condition": "desc", - "id": "sort_desc_checkbox_field" - }, - "sort_asc_checklist_field": { - "field_id": "checklist_field", - "condition": "asc", - "id": "sort_asc_checklist_field" - }, - "sort_desc_checklist_field": { - "field_id": "checklist_field", - "condition": "desc", - "id": "sort_desc_checklist_field" - }, - "sort_asc_single_select_field": { - "field_id": "single_select_field", - "condition": "asc", - "id": "sort_asc_single_select_field" - }, - "sort_desc_single_select_field": { - "field_id": "single_select_field", - "condition": "desc", - "id": "sort_desc_single_select_field" - }, - "sort_asc_multi_select_field": { - "field_id": "multi_select_field", - "condition": "asc", - "id": "sort_asc_multi_select_field" - }, - "sort_desc_multi_select_field": { - "field_id": "multi_select_field", - "condition": "desc", - "id": "sort_desc_multi_select_field" - }, - "sort_asc_url_field": { - "field_id": "url_field", - "condition": "asc", - "id": "sort_asc_url_field" - }, - "sort_desc_url_field": { - "field_id": "url_field", - "condition": "desc", - "id": "sort_desc_url_field" - }, - "sort_asc_created_at": { - "field_id": "created_at_field", - "condition": "asc", - "id": "sort_asc_created_at" - }, - "sort_desc_created_at": { - "field_id": "created_at_field", - "condition": "desc", - "id": "sort_desc_created_at" - }, - "sort_asc_updated_at": { - "field_id": "last_modified_field", - "condition": "asc", - "id": "sort_asc_updated_at" - }, - "sort_desc_updated_at": { - "field_id": "last_modified_field", - "condition": "desc", - "id": "sort_desc_updated_at" - } -} \ No newline at end of file diff --git a/src/application/database-yjs/__tests__/group.test.ts b/src/application/database-yjs/__tests__/group.test.ts deleted file mode 100644 index a38ed139..00000000 --- a/src/application/database-yjs/__tests__/group.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { FieldType, Row } from '@/application/database-yjs'; -import { withTestingData } from '@/application/database-yjs/__tests__/withTestingData'; -import { withTestingRows } from '@/application/database-yjs/__tests__/withTestingRows'; -import { expect } from '@jest/globals'; -import { groupByField } from '../group'; -import * as Y from 'yjs'; -import { - YDatabaseField, - YDatabaseFieldTypeOption, - YjsDatabaseKey, - YjsEditorKey, - YMapFieldTypeOption, -} from '@/application/types'; -import { YjsEditor } from '@/application/slate-yjs'; - -describe('Database group', () => { - let rows: Row[]; - - beforeEach(() => { - rows = withTestingRows(); - }); - - it('should return undefined if field is not select option', () => { - const { fields, rowMap } = withTestingData(); - expect(groupByField(rows, rowMap, fields.get('text_field'))).toBeUndefined(); - expect(groupByField(rows, rowMap, fields.get('number_field'))).toBeUndefined(); - expect(groupByField(rows, rowMap, fields.get('checklist_field'))).toBeUndefined(); - }); - - it('should gourp by checkbox field', () => { - const { fields, rowMap } = withTestingData(); - const field = fields.get('checkbox_field'); - const result = groupByField(rows, rowMap, field); - const expectRes = new Map([ - [ - 'Yes', - [ - { id: '1', height: 37 }, - { id: '3', height: 37 }, - { id: '5', height: 37 }, - { id: '7', height: 37 }, - { id: '9', height: 37 }, - ], - ], - [ - 'No', - [ - { id: '2', height: 37 }, - { id: '4', height: 37 }, - { id: '6', height: 37 }, - { id: '8', height: 37 }, - { id: '10', height: 37 }, - ], - ], - ]); - expect(result).toEqual(expectRes); - }); - it('should group by select option field', () => { - const { fields, rowMap } = withTestingData(); - const field = fields.get('single_select_field'); - const result = groupByField(rows, rowMap, field); - const expectRes = new Map([ - [ - '1', - [ - { id: '1', height: 37 }, - { id: '4', height: 37 }, - { id: '7', height: 37 }, - { id: '10', height: 37 }, - ], - ], - [ - '2', - [ - { id: '2', height: 37 }, - { id: '5', height: 37 }, - { id: '8', height: 37 }, - ], - ], - [ - '3', - [ - { id: '3', height: 37 }, - { id: '6', height: 37 }, - { id: '9', height: 37 }, - ], - ], - ]); - expect(result).toEqual(expectRes); - }); - - it('should group by multi select option field', () => { - const { fields, rowMap } = withTestingData(); - const field = fields.get('multi_select_field'); - const result = groupByField(rows, rowMap, field); - const expectRes = new Map([ - [ - '1', - [ - { id: '1', height: 37 }, - { id: '3', height: 37 }, - { id: '5', height: 37 }, - { id: '6', height: 37 }, - { id: '7', height: 37 }, - { id: '9', height: 37 }, - ], - ], - [ - '2', - [ - { id: '1', height: 37 }, - { id: '2', height: 37 }, - { id: '4', height: 37 }, - { id: '5', height: 37 }, - { id: '7', height: 37 }, - { id: '8', height: 37 }, - { id: '10', height: 37 }, - ], - ], - [ - '3', - [ - { id: '2', height: 37 }, - { id: '3', height: 37 }, - { id: '5', height: 37 }, - { id: '6', height: 37 }, - { id: '8', height: 37 }, - { id: '9', height: 37 }, - ], - ], - ]); - expect(result).toEqual(expectRes); - }); - - it('should not group if no options', () => { - const { fields, rowMap } = withTestingData(); - const field = new Y.Map() as YDatabaseField; - const typeOption = new Y.Map() as YDatabaseFieldTypeOption; - const now = Date.now().toString(); - - field.set(YjsDatabaseKey.name, 'Single Select Field'); - field.set(YjsDatabaseKey.id, 'another_single_select_field'); - field.set(YjsDatabaseKey.type, String(FieldType.SingleSelect)); - field.set(YjsDatabaseKey.last_modified, now.valueOf()); - field.set(YjsDatabaseKey.type_option, typeOption); - fields.set('another_single_select_field', field); - expect(groupByField(rows, rowMap, field)).toBeUndefined(); - - const selectTypeOption = new Y.Map() as YMapFieldTypeOption; - - typeOption.set(String(FieldType.SingleSelect), selectTypeOption); - selectTypeOption.set(YjsDatabaseKey.content, JSON.stringify({ disable_color: false, options: [] })); - const expectRes = new Map([['another_single_select_field', rows]]); - expect(groupByField(rows, rowMap, field)).toEqual(expectRes); - }); - - it('should handle empty selected ids', () => { - const { fields, rowMap } = withTestingData(); - const cell = rowMap['1'] - ?.getMap(YjsEditorKey.data_section) - ?.get(YjsEditorKey.database_row) - ?.get(YjsDatabaseKey.cells) - ?.get('single_select_field'); - cell?.set(YjsDatabaseKey.data, null); - - const field = fields.get('single_select_field'); - const result = groupByField(rows, rowMap, field); - expect(result).toEqual( - new Map([ - ['single_select_field', [{ id: '1', height: 37 }]], - [ - '2', - [ - { id: '2', height: 37 }, - { id: '5', height: 37 }, - { id: '8', height: 37 }, - ], - ], - [ - '3', - [ - { id: '3', height: 37 }, - { id: '6', height: 37 }, - { id: '9', height: 37 }, - ], - ], - [ - '1', - [ - { id: '4', height: 37 }, - { id: '7', height: 37 }, - { id: '10', height: 37 }, - ], - ], - ]), - ); - }); -}); diff --git a/src/application/database-yjs/__tests__/parse.test.ts b/src/application/database-yjs/__tests__/parse.test.ts deleted file mode 100644 index 52e6df60..00000000 --- a/src/application/database-yjs/__tests__/parse.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { parseYDatabaseCellToCell } from '@/application/database-yjs/cell.parse'; -import { expect } from '@jest/globals'; -import { withTestingCheckboxCell, withTestingDateCell } from '@/application/database-yjs/__tests__/withTestingCell'; -import * as Y from 'yjs'; -import { - FieldType, - parseSelectOptionTypeOptions, - parseRelationTypeOption, - parseNumberTypeOptions, -} from '@/application/database-yjs'; -import { YDatabaseField, YDatabaseFieldTypeOption, YjsDatabaseKey } from '@/application/types'; -import { - withNumberTestingField, - withRelationTestingField, -} from '@/application/database-yjs/__tests__/withTestingField'; - -describe('parseYDatabaseCellToCell', () => { - it('should parse a DateTime cell', () => { - const doc = new Y.Doc(); - const cell = withTestingDateCell(); - doc.getMap('cells').set('date_field', cell); - const parsedCell = parseYDatabaseCellToCell(cell); - expect(parsedCell.data).not.toBe(undefined); - expect(parsedCell.createdAt).not.toBe(undefined); - expect(parsedCell.lastModified).not.toBe(undefined); - expect(parsedCell.fieldType).toBe(Number(FieldType.DateTime)); - }); - it('should parse a Checkbox cell', () => { - const doc = new Y.Doc(); - const cell = withTestingCheckboxCell(); - doc.getMap('cells').set('checkbox_field', cell); - const parsedCell = parseYDatabaseCellToCell(cell); - expect(parsedCell.data).toBe(true); - expect(parsedCell.createdAt).not.toBe(undefined); - expect(parsedCell.lastModified).not.toBe(undefined); - expect(parsedCell.fieldType).toBe(Number(FieldType.Checkbox)); - }); -}); - -describe('Select option field parse', () => { - it('should parse select option type options', () => { - const doc = new Y.Doc(); - const field = new Y.Map() as YDatabaseField; - const typeOption = new Y.Map() as YDatabaseFieldTypeOption; - const now = Date.now().toString(); - - field.set(YjsDatabaseKey.name, 'Single Select Field'); - field.set(YjsDatabaseKey.id, 'single_select_field'); - field.set(YjsDatabaseKey.type, String(FieldType.SingleSelect)); - field.set(YjsDatabaseKey.last_modified, now.valueOf()); - field.set(YjsDatabaseKey.type_option, typeOption); - doc.getMap('fields').set('single_select_field', field); - expect(parseSelectOptionTypeOptions(field)).toEqual(null); - }); -}); - -describe('number field parse', () => { - it('should parse number field', () => { - const doc = new Y.Doc(); - const field = withNumberTestingField(); - doc.getMap('fields').set('number_field', field); - expect(parseNumberTypeOptions(field)).toEqual({ - format: 0, - }); - }); -}); - -describe('relation field parse', () => { - it('should parse relation field', () => { - const doc = new Y.Doc(); - const field = withRelationTestingField(); - doc.getMap('fields').set('relation_field', field); - expect(parseRelationTypeOption(field)).toEqual(undefined); - }); -}); diff --git a/src/application/database-yjs/__tests__/selector.test.tsx b/src/application/database-yjs/__tests__/selector.test.tsx deleted file mode 100644 index aaa38c69..00000000 --- a/src/application/database-yjs/__tests__/selector.test.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import { - useCellSelector, - useFieldSelector, - useFieldsSelector, - useFilterSelector, - useFiltersSelector, - useGroup, - useGroupsSelector, - usePrimaryFieldId, - useRowDataSelector, - useRowMetaSelector, - useRowOrdersSelector, - useRowsByGroup, - useSortSelector, - useSortsSelector, -} from '../selector'; -import { useDatabaseViewId } from '../context'; -import { DatabaseContextProvider } from '@/components/database/DatabaseContext'; -import { withTestingDatabase } from '@/application/database-yjs/__tests__/withTestingData'; -import { expect } from '@jest/globals'; -import { YDoc, YjsDatabaseKey, YjsEditorKey, YSharedRoot } from '@/application/types'; -import * as Y from 'yjs'; -import { withNumberTestingField, withTestingFields } from '@/application/database-yjs/__tests__/withTestingField'; -import { withTestingRows } from '@/application/database-yjs/__tests__/withTestingRows'; - -const wrapperCreator = - (viewId: string, doc: YDoc, rowDocMap: Record) => - ({ children }: { children: React.ReactNode }) => { - return ( - - {children} - - ); - }; - -describe('Database selector', () => { - let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element; - let rowDocMap: Record; - let doc: YDoc; - - beforeEach(() => { - const data = withTestingDatabase('1'); - - doc = data.doc; - rowDocMap = data.rowDocMap; - wrapper = wrapperCreator('1', doc, rowDocMap); - }); - - it('should select a field', () => { - const { result } = renderHook(() => useFieldSelector('number_field'), { wrapper }); - - const tempDoc = new Y.Doc(); - const field = withNumberTestingField(); - - tempDoc.getMap().set('number_field', field); - - expect(result.current.field?.toJSON()).toEqual(field.toJSON()); - }); - - it('should select all fields', () => { - const { result } = renderHook(() => useFieldsSelector(), { wrapper }); - - expect(result.current.map((item) => item.fieldId)).toEqual(Array.from(withTestingFields().keys())); - }); - - it('should select all filters', () => { - const { result } = renderHook(() => useFiltersSelector(), { wrapper }); - - expect(result.current).toEqual(['filter_multi_select_field']); - }); - - it('should select a filter', () => { - const { result } = renderHook(() => useFilterSelector('filter_multi_select_field'), { wrapper }); - - expect(result.current).toEqual({ - content: '1,3', - condition: 2, - fieldId: 'multi_select_field', - id: 'filter_multi_select_field', - filterType: NaN, - optionIds: ['1', '3'], - }); - }); - - it('should select all sorts', () => { - const { result } = renderHook(() => useSortsSelector(), { wrapper }); - - expect(result.current).toEqual(['sort_asc_text_field']); - }); - - it('should select a sort', () => { - const { result } = renderHook(() => useSortSelector('sort_asc_text_field'), { wrapper }); - - expect(result.current).toEqual({ - fieldId: 'text_field', - id: 'sort_asc_text_field', - condition: 0, - }); - }); - - it('should select all groups', () => { - const { result } = renderHook(() => useGroupsSelector(), { wrapper }); - - expect(result.current).toEqual(['g:single_select_field']); - }); - - it('should select a group', () => { - const { result } = renderHook(() => useGroup('g:single_select_field'), { wrapper }); - - expect(result.current).toEqual({ - fieldId: 'single_select_field', - columns: [ - { - id: '1', - visible: true, - }, - { - id: 'single_select_field', - visible: true, - }, - ], - }); - }); - - it('should select rows by group', () => { - const { result } = renderHook(() => useRowsByGroup('g:single_select_field'), { wrapper }); - - const { fieldId, columns, notFound, groupResult } = result.current; - - expect(fieldId).toEqual('single_select_field'); - expect(columns).toEqual([ - { - id: '1', - visible: true, - }, - { - id: 'single_select_field', - visible: true, - }, - ]); - expect(notFound).toBeFalsy(); - - expect(groupResult).toEqual( - new Map([ - [ - '1', - [ - { id: '1', height: 37 }, - { id: '7', height: 37 }, - ], - ], - [ - '2', - [ - { id: '2', height: 37 }, - { id: '8', height: 37 }, - { id: '5', height: 37 }, - ], - ], - [ - '3', - [ - { id: '9', height: 37 }, - { id: '3', height: 37 }, - { id: '6', height: 37 }, - ], - ], - ]), - ); - }); - - it('should select all row orders', () => { - const { result } = renderHook(() => useRowOrdersSelector(), { wrapper }); - - expect(result.current?.map((item) => item.id).join(',')).toEqual('9,2,3,1,6,8,5,7'); - }); - - it('should select a row data', () => { - const rows = withTestingRows(); - const { result } = renderHook(() => useRowDataSelector(rows[0].id), { wrapper }); - - expect(result.current.row?.toJSON()).toEqual( - rowDocMap[rows[0].id]?.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database_row)?.toJSON(), - ); - }); - - it('should select a cell', () => { - const rows = withTestingRows(); - const { result } = renderHook( - () => - useCellSelector({ - rowId: rows[0].id, - fieldId: 'number_field', - }), - { wrapper }, - ); - - expect(result.current).toEqual({ - createdAt: NaN, - data: 123, - fieldType: 1, - lastModified: NaN, - }); - }); - - it('should select a primary field id', () => { - const { result } = renderHook(() => usePrimaryFieldId(), { wrapper }); - - expect(result.current).toEqual('text_field'); - }); - - it('should select a row meta', () => { - const rows = withTestingRows(); - const { result } = renderHook(() => useRowMetaSelector(rows[0].id), { wrapper }); - - expect(result.current?.documentId).not.toBeNull(); - }); - - it('should select view id', () => { - const { result } = renderHook(() => useDatabaseViewId(), { wrapper }); - - expect(result.current).toEqual('1'); - }); - - it('should select all rows if filter is not found', () => { - const view = (doc.get(YjsEditorKey.data_section) as YSharedRoot) - .get(YjsEditorKey.database) - .get(YjsDatabaseKey.views) - .get('1'); - - view.set(YjsDatabaseKey.filters, new Y.Array()); - - const { result } = renderHook(() => useRowOrdersSelector(), { wrapper }); - - expect(result.current?.map((item) => item.id).join(',')).toEqual('9,2,3,4,1,6,10,8,5,7'); - }); - - it('should select original row orders if sorts is not found', () => { - const view = (doc.get(YjsEditorKey.data_section) as YSharedRoot) - .get(YjsEditorKey.database) - .get(YjsDatabaseKey.views) - .get('1'); - - view.set(YjsDatabaseKey.sorts, new Y.Array()); - - const { result } = renderHook(() => useRowOrdersSelector(), { wrapper }); - - expect(result.current?.map((item) => item.id).join(',')).toEqual('1,2,3,5,6,7,8,9'); - }); - - it('should select all rows if filters and sorts are not found', () => { - const view = (doc.get(YjsEditorKey.data_section) as YSharedRoot) - .get(YjsEditorKey.database) - .get(YjsDatabaseKey.views) - .get('1'); - - view.set(YjsDatabaseKey.filters, new Y.Array()); - view.set(YjsDatabaseKey.sorts, new Y.Array()); - - const { result } = renderHook(() => useRowOrdersSelector(), { wrapper }); - - expect(result.current?.map((item) => item.id).join(',')).toEqual('1,2,3,4,5,6,7,8,9,10'); - }); -}); diff --git a/src/application/database-yjs/__tests__/sort.test.ts b/src/application/database-yjs/__tests__/sort.test.ts deleted file mode 100644 index ce41777e..00000000 --- a/src/application/database-yjs/__tests__/sort.test.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { Row } from '@/application/database-yjs'; -import { withTestingData } from '@/application/database-yjs/__tests__/withTestingData'; -import { withTestingRows } from '@/application/database-yjs/__tests__/withTestingRows'; -import { - withCheckboxSort, - withChecklistSort, - withCreatedAtSort, - withDateTimeSort, - withLastModifiedSort, - withMultiSelectOptionSort, - withNumberSort, - withRichTextSort, - withSingleSelectOptionSort, - withUrlSort, -} from '@/application/database-yjs/__tests__/withTestingSorts'; -import { - withCheckboxTestingField, - withDateTimeTestingField, - withNumberTestingField, - withRichTextTestingField, - withSelectOptionTestingField, - withURLTestingField, - withChecklistTestingField, - withRelationTestingField, -} from './withTestingField'; -import { sortBy, parseCellDataForSort } from '../sort'; -import * as Y from 'yjs'; -import { expect } from '@jest/globals'; -import { RowId, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; - -describe('parseCellDataForSort', () => { - it('should parse data correctly based on field type', () => { - const doc = new Y.Doc(); - const field = withNumberTestingField(); - doc.getMap().set('field', field); - const data = 42; - - const result = parseCellDataForSort(field, data); - - expect(result).toEqual(data); - }); - - it('should return default value for empty rich text', () => { - const doc = new Y.Doc(); - const field = withRichTextTestingField(); - doc.getMap().set('field', field); - const data = ''; - - const result = parseCellDataForSort(field, data); - - expect(result).toEqual('\uFFFF'); - }); - - it('should return default value for empty URL', () => { - const doc = new Y.Doc(); - const field = withURLTestingField(); - doc.getMap().set('field', field); - const data = ''; - - const result = parseCellDataForSort(field, data); - - expect(result).toBe('\uFFFF'); - }); - - it('should return data for non-empty rich text', () => { - const doc = new Y.Doc(); - const field = withRichTextTestingField(); - doc.getMap().set('field', field); - const data = 'Hello, world!'; - - const result = parseCellDataForSort(field, data); - - expect(result).toBe(data); - }); - - it('should parse checkbox data correctly', () => { - const doc = new Y.Doc(); - const field = withCheckboxTestingField(); - doc.getMap().set('field', field); - const data = 'Yes'; - - const result = parseCellDataForSort(field, data); - - expect(result).toBe(true); - - const noData = 'No'; - const noResult = parseCellDataForSort(field, noData); - expect(noResult).toBe(false); - }); - - it('should parse DateTime data correctly', () => { - const doc = new Y.Doc(); - const field = withDateTimeTestingField(); - doc.getMap().set('field', field); - const data = '1633046400000'; - - const result = parseCellDataForSort(field, data); - - expect(result).toBe(Number(data)); - }); - - it('should parse SingleSelect data correctly', () => { - const doc = new Y.Doc(); - const field = withSelectOptionTestingField(); - doc.getMap().set('field', field); - const data = '1'; - - const result = parseCellDataForSort(field, data); - - expect(result).toBe('Option 1'); - }); - - it('should parse MultiSelect data correctly', () => { - const doc = new Y.Doc(); - const field = withSelectOptionTestingField(); - doc.getMap().set('field', field); - const data = '1,2'; - - const result = parseCellDataForSort(field, data); - - expect(result).toBe('Option 1, Option 2'); - }); - - it('should parse Checklist data correctly', () => { - const doc = new Y.Doc(); - const field = withChecklistTestingField(); - doc.getMap().set('field', field); - const data = '[]'; - - const result = parseCellDataForSort(field, data); - - expect(result).toBe(0); - }); - - it('should return empty string for Relation field', () => { - const doc = new Y.Doc(); - const field = withRelationTestingField(); - doc.getMap().set('field', field); - const data = ''; - - const result = parseCellDataForSort(field, data); - - expect(result).toBe(''); - }); -}); - -describe('Database sortBy', () => { - let rows: Row[]; - - beforeEach(() => { - rows = withTestingRows(); - }); - - it('should not sort rows if no sort is provided', () => { - const { sorts, fields, rowMap } = withTestingData(); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10'); - }); - - it('should not sort rows if no rows are provided', () => { - const { sorts, fields } = withTestingData(); - const rowMap: Record = {}; - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10'); - }); - - it('should return default data if rowMeta is not found', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withNumberSort(); - sorts.push([sort]); - delete rowMap['1']; - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10'); - }); - - it('should return default data if cell is not found', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withNumberSort(); - sorts.push([sort]); - const rowDoc = rowMap['1']; - rowDoc - ?.getMap(YjsEditorKey.data_section) - .get(YjsEditorKey.database_row) - ?.get(YjsDatabaseKey.cells) - .delete('number_field'); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10'); - }); - - it('should sort by number field in ascending order', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withNumberSort(); - sorts.push([sort]); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10'); - }); - - it('should sort by number field in descending order', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withNumberSort(false); - sorts.push([sort]); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('10,9,8,7,6,5,4,3,2,1'); - }); - - it('should sort by rich text field in ascending order', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withRichTextSort(); - sorts.push([sort]); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('9,2,3,4,1,6,10,8,5,7'); - }); - - it('should sort by rich text field in descending order', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withRichTextSort(false); - sorts.push([sort]); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('7,5,8,10,6,1,4,3,2,9'); - }); - - it('should sort by url field in ascending order', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withUrlSort(); - sorts.push([sort]); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('1,10,2,3,4,5,6,7,8,9'); - }); - - it('should sort by url field in descending order', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withUrlSort(false); - sorts.push([sort]); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('9,8,7,6,5,4,3,2,10,1'); - }); - - it('should sort by checkbox field in ascending order', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withCheckboxSort(); - sorts.push([sort]); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('2,4,6,8,10,1,3,5,7,9'); - }); - - it('should sort by checkbox field in descending order', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withCheckboxSort(false); - sorts.push([sort]); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('1,3,5,7,9,2,4,6,8,10'); - }); - - it('should sort by DateTime field in ascending order', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withDateTimeSort(); - sorts.push([sort]); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10'); - }); - - it('should sort by DateTime field in descending order', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withDateTimeSort(false); - sorts.push([sort]); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('10,9,8,7,6,5,4,3,2,1'); - }); - - it('should sort by SingleSelect field in ascending order', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withSingleSelectOptionSort(); - sorts.push([sort]); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('1,4,7,10,2,5,8,3,6,9'); - }); - - it('should sort by SingleSelect field in descending order', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withSingleSelectOptionSort(false); - sorts.push([sort]); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('3,6,9,2,5,8,1,4,7,10'); - }); - - it('should sort by MultiSelect field in ascending order', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withMultiSelectOptionSort(); - sorts.push([sort]); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('1,7,5,3,6,9,4,10,2,8'); - }); - - it('should sort by MultiSelect field in descending order', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withMultiSelectOptionSort(false); - sorts.push([sort]); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('2,8,4,10,3,6,9,5,1,7'); - }); - - it('should sort by Checklist field in ascending order', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withChecklistSort(); - sorts.push([sort]); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('4,10,1,2,5,6,7,8,3,9'); - }); - - it('should sort by Checklist field in descending order', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withChecklistSort(false); - sorts.push([sort]); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('3,9,1,2,5,6,7,8,4,10'); - }); - - it('should sort by CreatedAt field in ascending order', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withCreatedAtSort(); - sorts.push([sort]); - - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10'); - }); - - it('should sort by LastEditedTime field', () => { - const { sorts, fields, rowMap } = withTestingData(); - const sort = withLastModifiedSort(); - sorts.push([sort]); - const sortedRows = sortBy(rows, sorts, fields, rowMap) - .map((row) => row.id) - .join(','); - expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10'); - }); -}); diff --git a/src/application/database-yjs/__tests__/withTestingCell.ts b/src/application/database-yjs/__tests__/withTestingCell.ts deleted file mode 100644 index de0f7d68..00000000 --- a/src/application/database-yjs/__tests__/withTestingCell.ts +++ /dev/null @@ -1,43 +0,0 @@ -import * as Y from 'yjs'; -import { YDatabaseCell, YjsDatabaseKey } from '@/application/types'; -import { FieldType } from '@/application/database-yjs'; - -export function withTestingDateCell() { - const cell = new Y.Map() as YDatabaseCell; - - cell.set(YjsDatabaseKey.id, 'date_field'); - cell.set(YjsDatabaseKey.data, Date.now()); - cell.set(YjsDatabaseKey.field_type, Number(FieldType.DateTime)); - cell.set(YjsDatabaseKey.created_at, Date.now()); - cell.set(YjsDatabaseKey.last_modified, Date.now()); - cell.set(YjsDatabaseKey.end_timestamp, Date.now() + 1000); - cell.set(YjsDatabaseKey.include_time, true); - cell.set(YjsDatabaseKey.is_range, true); - cell.set(YjsDatabaseKey.reminder_id, 'reminderId'); - - return cell; -} - -export function withTestingCheckboxCell() { - const cell = new Y.Map() as YDatabaseCell; - - cell.set(YjsDatabaseKey.id, 'checkbox_field'); - cell.set(YjsDatabaseKey.data, 'Yes'); - cell.set(YjsDatabaseKey.field_type, Number(FieldType.Checkbox)); - cell.set(YjsDatabaseKey.created_at, Date.now()); - cell.set(YjsDatabaseKey.last_modified, Date.now()); - - return cell; -} - -export function withTestingSingleOptionCell() { - const cell = new Y.Map() as YDatabaseCell; - - cell.set(YjsDatabaseKey.id, 'single_select_field'); - cell.set(YjsDatabaseKey.data, 'optionId'); - cell.set(YjsDatabaseKey.field_type, Number(FieldType.SingleSelect)); - cell.set(YjsDatabaseKey.created_at, Date.now()); - cell.set(YjsDatabaseKey.last_modified, Date.now()); - - return cell; -} diff --git a/src/application/database-yjs/__tests__/withTestingData.ts b/src/application/database-yjs/__tests__/withTestingData.ts deleted file mode 100644 index 282590eb..00000000 --- a/src/application/database-yjs/__tests__/withTestingData.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { - RowId, - YDatabase, - YDatabaseFields, - YDatabaseFilters, - YDatabaseGroup, - YDatabaseGroupColumn, - YDatabaseGroupColumns, - YDatabaseLayoutSettings, - YDatabaseSorts, - YDatabaseView, - YDatabaseViews, - YDoc, - YjsDatabaseKey, - YjsEditorKey, -} from '@/application/types'; -import { withTestingFields } from '@/application/database-yjs/__tests__/withTestingField'; -import { - withTestingRowData, - withTestingRowDataMap, - withTestingRows, -} from '@/application/database-yjs/__tests__/withTestingRows'; -import * as Y from 'yjs'; -import { withMultiSelectOptionFilter } from '@/application/database-yjs/__tests__/withTestingFilters'; -import { withRichTextSort } from '@/application/database-yjs/__tests__/withTestingSorts'; -import { metaIdFromRowId, RowMetaKey } from '@/application/database-yjs'; - -export function withTestingData () { - const doc = new Y.Doc(); - const sharedRoot = doc.getMap(); - const fields = withTestingFields() as YDatabaseFields; - - sharedRoot.set('fields', fields); - - const rowMap = withTestingRowDataMap(); - - sharedRoot.set('rows', rowMap); - - const sorts = new Y.Array() as YDatabaseSorts; - - sharedRoot.set('sorts', sorts); - - const filters = new Y.Array() as YDatabaseFilters; - - sharedRoot.set('filters', filters); - - return { - fields, - rowMap, - sorts, - filters, - doc, - }; -} - -export function withTestingDatabase (viewId: string) { - const doc = new Y.Doc(); - const sharedRoot = doc.getMap(YjsEditorKey.data_section); - const database = new Y.Map() as YDatabase; - - sharedRoot.set(YjsEditorKey.database, database); - - const fields = withTestingFields() as YDatabaseFields; - - database.set(YjsDatabaseKey.fields, fields); - database.set(YjsDatabaseKey.id, viewId); - - const metas = new Y.Map(); - - database.set(YjsDatabaseKey.metas, metas); - metas.set(YjsDatabaseKey.iid, viewId); - - const views = new Y.Map() as YDatabaseViews; - - database.set(YjsDatabaseKey.views, views); - - const view = new Y.Map() as YDatabaseView; - - views.set('1', view); - view.set(YjsDatabaseKey.id, viewId); - view.set(YjsDatabaseKey.layout, 0); - view.set(YjsDatabaseKey.name, 'View 1'); - view.set(YjsDatabaseKey.database_id, viewId); - - const layoutSetting = new Y.Map() as YDatabaseLayoutSettings; - - const calendarSetting = new Y.Map(); - - calendarSetting.set(YjsDatabaseKey.field_id, 'date_field'); - layoutSetting.set('2', calendarSetting); - - view.set(YjsDatabaseKey.layout_settings, layoutSetting); - - const filters = new Y.Array() as YDatabaseFilters; - const filter = withMultiSelectOptionFilter(); - - filters.push([filter]); - - const sorts = new Y.Array() as YDatabaseSorts; - const sort = withRichTextSort(); - - sorts.push([sort]); - - const groups = new Y.Array(); - const group = new Y.Map() as YDatabaseGroup; - - groups.push([group]); - group.set(YjsDatabaseKey.id, 'g:single_select_field'); - group.set(YjsDatabaseKey.field_id, 'single_select_field'); - group.set(YjsDatabaseKey.type, '3'); - group.set(YjsDatabaseKey.content, ''); - - const groupColumns = new Y.Array() as YDatabaseGroupColumns; - - group.set(YjsDatabaseKey.groups, groupColumns); - - const column1 = new Y.Map() as YDatabaseGroupColumn; - const column2 = new Y.Map() as YDatabaseGroupColumn; - - column1.set(YjsDatabaseKey.id, '1'); - column1.set(YjsDatabaseKey.visible, true); - column2.set(YjsDatabaseKey.id, 'single_select_field'); - column2.set(YjsDatabaseKey.visible, true); - - groupColumns.push([column1]); - groupColumns.push([column2]); - - view.set(YjsDatabaseKey.filters, filters); - view.set(YjsDatabaseKey.sorts, sorts); - view.set(YjsDatabaseKey.groups, groups); - - const fieldSettings = new Y.Map(); - const fieldOrder = new Y.Array(); - const rowOrders = new Y.Array(); - - fields.forEach((field) => { - const setting = new Y.Map(); - - const fieldId = field.get(YjsDatabaseKey.id); - - if (fieldId === 'text_field') { - field.set(YjsDatabaseKey.is_primary, true); - } - - fieldOrder.push([fieldId]); - fieldSettings.set(fieldId, setting); - setting.set(YjsDatabaseKey.visibility, 0); - }); - const rows = withTestingRows(); - - rows.forEach(({ id, height }) => { - const row = new Y.Map(); - - row.set(YjsDatabaseKey.id, id); - row.set(YjsDatabaseKey.height, height); - rowOrders.push([row]); - }); - - view.set(YjsDatabaseKey.field_settings, fieldSettings); - view.set(YjsDatabaseKey.field_orders, fieldOrder); - view.set(YjsDatabaseKey.row_orders, rowOrders); - - const rowMap: Record = {}; - - rows.forEach((row, index) => { - const rowDoc = new Y.Doc(); - const rowData = withTestingRowData(row.id, index); - const rowMeta = new Y.Map(); - const parser = metaIdFromRowId('281e76fb-712e-59e2-8370-678bf0788355'); - - rowMeta.set(parser(RowMetaKey.IconId), '😊'); - rowDoc.getMap(YjsEditorKey.data_section).set(YjsEditorKey.meta, rowMeta); - rowDoc.getMap(YjsEditorKey.data_section).set(YjsEditorKey.database_row, rowData); - rowMap[row.id] = rowDoc; - }); - - return { - rowDocMap: rowMap, - doc: doc as YDoc, - }; -} diff --git a/src/application/database-yjs/__tests__/withTestingField.ts b/src/application/database-yjs/__tests__/withTestingField.ts deleted file mode 100644 index e9329f34..00000000 --- a/src/application/database-yjs/__tests__/withTestingField.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { - YDatabaseField, - YDatabaseFieldTypeOption, - YjsDatabaseKey, - YMapFieldTypeOption, -} from '@/application/types'; -import { FieldType } from '@/application/database-yjs'; -import { SelectOptionColor } from '@/application/database-yjs/fields/select-option'; -import * as Y from 'yjs'; - -export function withTestingFields() { - const fields = new Y.Map(); - const textField = withRichTextTestingField(); - - fields.set('text_field', textField); - const numberField = withNumberTestingField(); - - fields.set('number_field', numberField); - - const checkboxField = withCheckboxTestingField(); - - fields.set('checkbox_field', checkboxField); - - const dateTimeField = withDateTimeTestingField(); - - fields.set('date_field', dateTimeField); - - const singleSelectField = withSelectOptionTestingField(); - - fields.set('single_select_field', singleSelectField); - const multipleSelectField = withSelectOptionTestingField(true); - - fields.set('multi_select_field', multipleSelectField); - - const urlField = withURLTestingField(); - - fields.set('url_field', urlField); - - const checklistField = withChecklistTestingField(); - - fields.set('checklist_field', checklistField); - - const createdAtField = withCreatedAtTestingField(); - - fields.set('created_at_field', createdAtField); - - const lastModifiedField = withLastModifiedTestingField(); - - fields.set('last_modified_field', lastModifiedField); - - return fields; -} - -export function withRichTextTestingField() { - const field = new Y.Map() as YDatabaseField; - const now = Date.now().toString(); - - field.set(YjsDatabaseKey.name, 'Rich Text Field'); - field.set(YjsDatabaseKey.id, 'text_field'); - field.set(YjsDatabaseKey.type, String(FieldType.RichText)); - field.set(YjsDatabaseKey.last_modified, now.valueOf()); - - return field; -} - -export function withNumberTestingField() { - const field = new Y.Map() as YDatabaseField; - - field.set(YjsDatabaseKey.name, 'Number Field'); - field.set(YjsDatabaseKey.id, 'number_field'); - field.set(YjsDatabaseKey.type, String(FieldType.Number)); - const typeOption = new Y.Map() as YDatabaseFieldTypeOption; - - const numberTypeOption = new Y.Map() as YMapFieldTypeOption; - - typeOption.set(String(FieldType.Number), numberTypeOption); - numberTypeOption.set(YjsDatabaseKey.format, '0'); - field.set(YjsDatabaseKey.type_option, typeOption); - - return field; -} - -export function withRelationTestingField() { - const field = new Y.Map() as YDatabaseField; - const typeOption = new Y.Map() as YDatabaseFieldTypeOption; - const now = Date.now().toString(); - - field.set(YjsDatabaseKey.name, 'Relation Field'); - field.set(YjsDatabaseKey.id, 'relation_field'); - field.set(YjsDatabaseKey.type, String(FieldType.Relation)); - field.set(YjsDatabaseKey.last_modified, now.valueOf()); - field.set(YjsDatabaseKey.type_option, typeOption); - - return field; -} - -export function withCheckboxTestingField() { - const field = new Y.Map() as YDatabaseField; - const now = Date.now().toString(); - - field.set(YjsDatabaseKey.name, 'Checkbox Field'); - field.set(YjsDatabaseKey.id, 'checkbox_field'); - field.set(YjsDatabaseKey.type, String(FieldType.Checkbox)); - field.set(YjsDatabaseKey.last_modified, now.valueOf()); - - return field; -} - -export function withDateTimeTestingField() { - const field = new Y.Map() as YDatabaseField; - const typeOption = new Y.Map() as YDatabaseFieldTypeOption; - const now = Date.now().toString(); - - field.set(YjsDatabaseKey.name, 'DateTime Field'); - field.set(YjsDatabaseKey.id, 'date_field'); - field.set(YjsDatabaseKey.type, String(FieldType.DateTime)); - field.set(YjsDatabaseKey.last_modified, now.valueOf()); - field.set(YjsDatabaseKey.type_option, typeOption); - - const dateTypeOption = new Y.Map() as YMapFieldTypeOption; - - typeOption.set(String(FieldType.DateTime), dateTypeOption); - - dateTypeOption.set(YjsDatabaseKey.time_format, '0'); - dateTypeOption.set(YjsDatabaseKey.date_format, '0'); - return field; -} - -export function withURLTestingField() { - const field = new Y.Map() as YDatabaseField; - const now = Date.now().toString(); - - field.set(YjsDatabaseKey.name, 'URL Field'); - field.set(YjsDatabaseKey.id, 'url_field'); - field.set(YjsDatabaseKey.type, String(FieldType.URL)); - field.set(YjsDatabaseKey.last_modified, now.valueOf()); - - return field; -} - -export function withSelectOptionTestingField(isMultiple = false) { - const field = new Y.Map() as YDatabaseField; - const typeOption = new Y.Map() as YDatabaseFieldTypeOption; - const now = Date.now().toString(); - - field.set(YjsDatabaseKey.name, 'Single Select Field'); - field.set(YjsDatabaseKey.id, isMultiple ? 'multi_select_field' : 'single_select_field'); - field.set(YjsDatabaseKey.type, String(FieldType.SingleSelect)); - field.set(YjsDatabaseKey.last_modified, now.valueOf()); - field.set(YjsDatabaseKey.type_option, typeOption); - - const selectTypeOption = new Y.Map() as YMapFieldTypeOption; - - typeOption.set(String(FieldType.SingleSelect), selectTypeOption); - - selectTypeOption.set( - YjsDatabaseKey.content, - JSON.stringify({ - disable_color: false, - options: [ - { id: '1', name: 'Option 1', color: SelectOptionColor.Purple }, - { id: '2', name: 'Option 2', color: SelectOptionColor.Pink }, - { id: '3', name: 'Option 3', color: SelectOptionColor.LightPink }, - ], - }) - ); - return field; -} - -export function withChecklistTestingField() { - const field = new Y.Map() as YDatabaseField; - const now = Date.now().toString(); - - field.set(YjsDatabaseKey.name, 'Checklist Field'); - field.set(YjsDatabaseKey.id, 'checklist_field'); - field.set(YjsDatabaseKey.type, String(FieldType.Checklist)); - field.set(YjsDatabaseKey.last_modified, now.valueOf()); - - return field; -} - -export function withCreatedAtTestingField() { - const field = new Y.Map() as YDatabaseField; - const now = Date.now().toString(); - - field.set(YjsDatabaseKey.name, 'Created At Field'); - field.set(YjsDatabaseKey.id, 'created_at_field'); - field.set(YjsDatabaseKey.type, String(FieldType.CreatedTime)); - field.set(YjsDatabaseKey.last_modified, now.valueOf()); - - return field; -} - -export function withLastModifiedTestingField() { - const field = new Y.Map() as YDatabaseField; - const now = Date.now().toString(); - - field.set(YjsDatabaseKey.name, 'Last Modified Field'); - field.set(YjsDatabaseKey.id, 'last_modified_field'); - field.set(YjsDatabaseKey.type, String(FieldType.LastEditedTime)); - field.set(YjsDatabaseKey.last_modified, now.valueOf()); - - return field; -} diff --git a/src/application/database-yjs/__tests__/withTestingFilters.ts b/src/application/database-yjs/__tests__/withTestingFilters.ts deleted file mode 100644 index 57a64402..00000000 --- a/src/application/database-yjs/__tests__/withTestingFilters.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { YDatabaseFilter, YjsDatabaseKey } from '@/application/types'; -import * as Y from 'yjs'; -import * as filtersJson from './fixtures/filters.json'; - -export function withRichTextFilter() { - const filter = new Y.Map() as YDatabaseFilter; - - filter.set(YjsDatabaseKey.id, 'filter_text_field'); - filter.set(YjsDatabaseKey.field_id, filtersJson.filter_text_field.field_id); - filter.set(YjsDatabaseKey.condition, filtersJson.filter_text_field.condition); - filter.set(YjsDatabaseKey.content, filtersJson.filter_text_field.content); - return filter; -} - -export function withUrlFilter() { - const filter = new Y.Map() as YDatabaseFilter; - - filter.set(YjsDatabaseKey.id, 'filter_url_field'); - filter.set(YjsDatabaseKey.field_id, filtersJson.filter_url_field.field_id); - filter.set(YjsDatabaseKey.condition, filtersJson.filter_url_field.condition); - filter.set(YjsDatabaseKey.content, filtersJson.filter_url_field.content); - return filter; -} - -export function withNumberFilter() { - const filter = new Y.Map() as YDatabaseFilter; - - filter.set(YjsDatabaseKey.id, 'filter_number_field'); - filter.set(YjsDatabaseKey.field_id, filtersJson.filter_number_field.field_id); - filter.set(YjsDatabaseKey.condition, filtersJson.filter_number_field.condition); - filter.set(YjsDatabaseKey.content, filtersJson.filter_number_field.content); - return filter; -} - -export function withCheckboxFilter() { - const filter = new Y.Map() as YDatabaseFilter; - - filter.set(YjsDatabaseKey.id, 'filter_checkbox_field'); - filter.set(YjsDatabaseKey.field_id, filtersJson.filter_checkbox_field.field_id); - filter.set(YjsDatabaseKey.condition, filtersJson.filter_checkbox_field.condition); - filter.set(YjsDatabaseKey.content, ''); - return filter; -} - -export function withChecklistFilter() { - const filter = new Y.Map() as YDatabaseFilter; - - filter.set(YjsDatabaseKey.id, 'filter_checklist_field'); - filter.set(YjsDatabaseKey.field_id, filtersJson.filter_checklist_field.field_id); - filter.set(YjsDatabaseKey.condition, filtersJson.filter_checklist_field.condition); - filter.set(YjsDatabaseKey.content, ''); - return filter; -} - -export function withSingleSelectOptionFilter() { - const filter = new Y.Map() as YDatabaseFilter; - - filter.set(YjsDatabaseKey.id, 'filter_single_select_field'); - filter.set(YjsDatabaseKey.field_id, filtersJson.filter_single_select_field.field_id); - filter.set(YjsDatabaseKey.condition, filtersJson.filter_single_select_field.condition); - filter.set(YjsDatabaseKey.content, filtersJson.filter_single_select_field.content); - return filter; -} - -export function withMultiSelectOptionFilter() { - const filter = new Y.Map() as YDatabaseFilter; - - filter.set(YjsDatabaseKey.id, 'filter_multi_select_field'); - filter.set(YjsDatabaseKey.field_id, filtersJson.filter_multi_select_field.field_id); - filter.set(YjsDatabaseKey.condition, filtersJson.filter_multi_select_field.condition); - filter.set(YjsDatabaseKey.content, filtersJson.filter_multi_select_field.content); - return filter; -} - -export function withDateTimeFilter() { - const filter = new Y.Map() as YDatabaseFilter; - - filter.set(YjsDatabaseKey.id, 'filter_date_field'); - filter.set(YjsDatabaseKey.field_id, filtersJson.filter_date_field.field_id); - filter.set(YjsDatabaseKey.condition, filtersJson.filter_date_field.condition); - filter.set(YjsDatabaseKey.content, filtersJson.filter_date_field.content); - return filter; -} diff --git a/src/application/database-yjs/__tests__/withTestingRows.ts b/src/application/database-yjs/__tests__/withTestingRows.ts deleted file mode 100644 index bffaccf2..00000000 --- a/src/application/database-yjs/__tests__/withTestingRows.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - RowId, - YDatabaseCell, - YDatabaseCells, - YDatabaseRow, - YDoc, - YjsDatabaseKey, - YjsEditorKey, -} from '@/application/types'; -import { FieldType, Row } from '@/application/database-yjs'; -import * as Y from 'yjs'; -import * as rowsJson from './fixtures/rows.json'; - -export function withTestingRows (): Row[] { - return rowsJson.map((row) => { - return { - id: row.id, - height: 37, - }; - }); -} - -export function withTestingRowDataMap (): Record { - const folder: Record = {}; - const rows = withTestingRows(); - - rows.forEach((row, index) => { - const rowDoc = new Y.Doc(); - const rowData = withTestingRowData(row.id, index); - - rowDoc.getMap(YjsEditorKey.data_section).set(YjsEditorKey.database_row, rowData); - folder[row.id] = rowDoc; - }); - - return folder; -} - -export function withTestingRowData (id: string, index: number) { - const rowData = new Y.Map() as YDatabaseRow; - - rowData.set(YjsDatabaseKey.id, id); - rowData.set(YjsDatabaseKey.height, 37); - rowData.set(YjsDatabaseKey.last_modified, Date.now() + index * 1000); - rowData.set(YjsDatabaseKey.created_at, Date.now() + index * 1000); - - const cells = new Y.Map() as YDatabaseCells; - - const textFieldCell = withTestingCell(rowsJson[index].cells.text_field.data); - - textFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.RichText)); - cells.set('text_field', textFieldCell); - - const numberFieldCell = withTestingCell(rowsJson[index].cells.number_field.data); - - numberFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.Number)); - cells.set('number_field', numberFieldCell); - - const checkboxFieldCell = withTestingCell(rowsJson[index].cells.checkbox_field.data); - - checkboxFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.Checkbox)); - cells.set('checkbox_field', checkboxFieldCell); - - const dateTimeFieldCell = withTestingCell(rowsJson[index].cells.date_field.data); - - dateTimeFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.DateTime)); - cells.set('date_field', dateTimeFieldCell); - - const urlFieldCell = withTestingCell(rowsJson[index].cells.url_field.data); - - urlFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.URL)); - cells.set('url_field', urlFieldCell); - - const singleSelectFieldCell = withTestingCell(rowsJson[index].cells.single_select_field.data); - - singleSelectFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.SingleSelect)); - cells.set('single_select_field', singleSelectFieldCell); - - const multiSelectFieldCell = withTestingCell(rowsJson[index].cells.multi_select_field.data); - - multiSelectFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.MultiSelect)); - cells.set('multi_select_field', multiSelectFieldCell); - - const checlistFieldCell = withTestingCell(rowsJson[index].cells.checklist_field.data); - - checlistFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.Checklist)); - cells.set('checklist_field', checlistFieldCell); - - rowData.set(YjsDatabaseKey.cells, cells); - return rowData; -} - -export function withTestingCell (cellData: string | number) { - const cell = new Y.Map() as YDatabaseCell; - - cell.set(YjsDatabaseKey.data, cellData); - return cell; -} diff --git a/src/application/database-yjs/__tests__/withTestingSorts.ts b/src/application/database-yjs/__tests__/withTestingSorts.ts deleted file mode 100644 index d9421d5e..00000000 --- a/src/application/database-yjs/__tests__/withTestingSorts.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { YDatabaseSort, YjsDatabaseKey } from '@/application/types'; -import * as Y from 'yjs'; -import * as sortsJson from './fixtures/sorts.json'; - -export function withRichTextSort(isAscending: boolean = true) { - const sort = new Y.Map() as YDatabaseSort; - const sortJSON = isAscending ? sortsJson.sort_asc_text_field : sortsJson.sort_desc_text_field; - - sort.set(YjsDatabaseKey.id, sortJSON.id); - sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); - sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); - - return sort; -} - -export function withUrlSort(isAscending: boolean = true) { - const sort = new Y.Map() as YDatabaseSort; - const sortJSON = isAscending ? sortsJson.sort_asc_url_field : sortsJson.sort_desc_url_field; - - sort.set(YjsDatabaseKey.id, sortJSON.id); - sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); - sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); - - return sort; -} - -export function withNumberSort(isAscending: boolean = true) { - const sort = new Y.Map() as YDatabaseSort; - const sortJSON = isAscending ? sortsJson.sort_asc_number_field : sortsJson.sort_desc_number_field; - - sort.set(YjsDatabaseKey.id, sortJSON.id); - sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); - sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); - - return sort; -} - -export function withCheckboxSort(isAscending: boolean = true) { - const sort = new Y.Map() as YDatabaseSort; - const sortJSON = isAscending ? sortsJson.sort_asc_checkbox_field : sortsJson.sort_desc_checkbox_field; - - sort.set(YjsDatabaseKey.id, sortJSON.id); - sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); - sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); - - return sort; -} - -export function withDateTimeSort(isAscending: boolean = true) { - const sort = new Y.Map() as YDatabaseSort; - const sortJSON = isAscending ? sortsJson.sort_asc_date_field : sortsJson.sort_desc_date_field; - - sort.set(YjsDatabaseKey.id, sortJSON.id); - sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); - sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); - - return sort; -} - -export function withSingleSelectOptionSort(isAscending: boolean = true) { - const sort = new Y.Map() as YDatabaseSort; - const sortJSON = isAscending ? sortsJson.sort_asc_single_select_field : sortsJson.sort_desc_single_select_field; - - sort.set(YjsDatabaseKey.id, sortJSON.id); - sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); - sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); - - return sort; -} - -export function withMultiSelectOptionSort(isAscending: boolean = true) { - const sort = new Y.Map() as YDatabaseSort; - const sortJSON = isAscending ? sortsJson.sort_asc_multi_select_field : sortsJson.sort_desc_multi_select_field; - - sort.set(YjsDatabaseKey.id, sortJSON.id); - sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); - sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); - - return sort; -} - -export function withChecklistSort(isAscending: boolean = true) { - const sort = new Y.Map() as YDatabaseSort; - const sortJSON = isAscending ? sortsJson.sort_asc_checklist_field : sortsJson.sort_desc_checklist_field; - - sort.set(YjsDatabaseKey.id, sortJSON.id); - sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); - sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); - - return sort; -} - -export function withCreatedAtSort(isAscending: boolean = true) { - const sort = new Y.Map() as YDatabaseSort; - const sortJSON = isAscending ? sortsJson.sort_asc_created_at : sortsJson.sort_desc_created_at; - - sort.set(YjsDatabaseKey.id, sortJSON.id); - sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); - sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); - - return sort; -} - -export function withLastModifiedSort(isAscending: boolean = true) { - const sort = new Y.Map() as YDatabaseSort; - const sortJSON = isAscending ? sortsJson.sort_asc_updated_at : sortsJson.sort_desc_updated_at; - - sort.set(YjsDatabaseKey.id, sortJSON.id); - sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); - sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); - - return sort; -} diff --git a/src/application/database-yjs/cell.parse.ts b/src/application/database-yjs/cell.parse.ts index 8835e952..4e764625 100644 --- a/src/application/database-yjs/cell.parse.ts +++ b/src/application/database-yjs/cell.parse.ts @@ -1,9 +1,16 @@ -import { YDatabaseCell, YjsDatabaseKey } from '@/application/types'; -import { FieldType } from '@/application/database-yjs/database.type'; import * as Y from 'yjs'; -import { Cell, CheckboxCell, DateTimeCell, FileMediaCell, FileMediaCellData } from './cell.type'; -export function parseYDatabaseCommonCellToCell(cell: YDatabaseCell): Cell { +import { FieldType } from '@/application/database-yjs/database.type'; +import { + getDateCellStr, + parseChecklistData, + parseSelectOptionTypeOptions, +} from '@/application/database-yjs/fields'; +import { YDatabaseCell, YDatabaseField, YjsDatabaseKey } from '@/application/types'; + +import { Cell, DateTimeCell, FileMediaCell, FileMediaCellData } from './cell.type'; + +export function parseYDatabaseCommonCellToCell (cell: YDatabaseCell): Cell { return { createdAt: Number(cell.get(YjsDatabaseKey.created_at)), lastModified: Number(cell.get(YjsDatabaseKey.last_modified)), @@ -12,28 +19,38 @@ export function parseYDatabaseCommonCellToCell(cell: YDatabaseCell): Cell { }; } -export function parseYDatabaseCellToCell(cell: YDatabaseCell): Cell { - const fieldType = parseInt(cell.get(YjsDatabaseKey.field_type)); +export function parseYDatabaseCellToCell (cell: YDatabaseCell): Cell { + const cellType = parseInt(cell.get(YjsDatabaseKey.field_type)); - if (fieldType === FieldType.DateTime) { - return parseYDatabaseDateTimeCellToCell(cell); + let value = parseYDatabaseCommonCellToCell(cell); + + if (cellType === FieldType.DateTime) { + value = parseYDatabaseDateTimeCellToCell(cell); } - if (fieldType === FieldType.Checkbox) { - return parseYDatabaseCheckboxCellToCell(cell); + if (cellType === FieldType.FileMedia) { + value = parseYDatabaseFileMediaCellToCell(cell); } - if (fieldType === FieldType.FileMedia) { - return parseYDatabaseFileMediaCellToCell(cell); + if (cellType === FieldType.Relation) { + value = parseYDatabaseRelationCellToCell(cell); } - return parseYDatabaseCommonCellToCell(cell); + return value; } -export function parseYDatabaseDateTimeCellToCell(cell: YDatabaseCell): DateTimeCell { +export function parseYDatabaseDateTimeCellToCell (cell: YDatabaseCell): DateTimeCell { + let data = cell.get(YjsDatabaseKey.data); + + if (typeof data !== 'string' && typeof data !== 'number') { + data = ''; + } else { + data = String(data); + } + return { ...parseYDatabaseCommonCellToCell(cell), - data: cell.get(YjsDatabaseKey.data) as string, + data, fieldType: FieldType.DateTime, endTimestamp: cell.get(YjsDatabaseKey.end_timestamp), includeTime: cell.get(YjsDatabaseKey.include_time), @@ -42,8 +59,18 @@ export function parseYDatabaseDateTimeCellToCell(cell: YDatabaseCell): DateTimeC }; } -export function parseYDatabaseFileMediaCellToCell(cell: YDatabaseCell): FileMediaCell { +export function parseYDatabaseFileMediaCellToCell (cell: YDatabaseCell): FileMediaCell { const data = cell.get(YjsDatabaseKey.data) as Y.Array; + + if (!data || !(data instanceof Y.Array)) { + return { + ...parseYDatabaseCommonCellToCell(cell), + data: [], + fieldType: FieldType.FileMedia, + } as FileMediaCell; + } + + // Convert YArray to FileMediaCellData const dataJson = data.toJSON().map((item: string) => JSON.parse(item)) as FileMediaCellData; return { @@ -53,10 +80,84 @@ export function parseYDatabaseFileMediaCellToCell(cell: YDatabaseCell): FileMedi }; } -export function parseYDatabaseCheckboxCellToCell(cell: YDatabaseCell): CheckboxCell { +export function parseYDatabaseRelationCellToCell (cell: YDatabaseCell): Cell { + const data = cell.get(YjsDatabaseKey.data) as Y.Array; + + if (!data || !(data instanceof Y.Array)) { + return { + ...parseYDatabaseCommonCellToCell(cell), + fieldType: FieldType.Relation, + data: null, + }; + } + return { ...parseYDatabaseCommonCellToCell(cell), - data: cell.get(YjsDatabaseKey.data) === 'Yes', - fieldType: FieldType.Checkbox, + fieldType: FieldType.Relation, + data: data, }; } + +export function getCellDataText (cell: YDatabaseCell, field: YDatabaseField): string { + const type = parseInt(field.get(YjsDatabaseKey.type)); + + switch (type) { + case FieldType.SingleSelect: + case FieldType.MultiSelect: { + const data = cell.get(YjsDatabaseKey.data); + const options = parseSelectOptionTypeOptions(field)?.options || []; + + if (typeof data === 'string') { + return data.split(',').map((item) => { + return options?.find((option) => option.id === item)?.name; + }).filter(item => item).join(',') || ''; + } + + return ''; + } + + case FieldType.Checklist: { + const cellData = cell.get(YjsDatabaseKey.data); + + if (typeof cellData === 'string') { + const { + options = [], + selectedOptionIds = [], + } = parseChecklistData(cellData) || {}; + + const completed = options + .filter((option) => selectedOptionIds.includes(option.id)) + .map((option, index) => `${index + 1}. ${option.name}`) + .join(',') || ''; + const incomplete = options.filter((option) => !selectedOptionIds.includes(option.id)).map((option, index) => `${index + 1}. ${option.name}`) + .join(',') || ''; + + return `Completed:${completed};Incomplete:${incomplete}`; + } + + return ''; + } + + case FieldType.DateTime: + {const dateCell = parseYDatabaseDateTimeCellToCell(cell); + + return getDateCellStr({ cell: dateCell, field });} + + case FieldType.CreatedTime: + case FieldType.LastEditedTime: + case FieldType.Relation: + case FieldType.AITranslations: + case FieldType.AISummaries: + return ''; + + default: { + const data = cell.get(YjsDatabaseKey.data); + + if (typeof data === 'string' || typeof data === 'number') { + return String(data); + } + + return ''; + } + } +} \ No newline at end of file diff --git a/src/application/database-yjs/cell.type.ts b/src/application/database-yjs/cell.type.ts index 5a102742..a98de4e7 100644 --- a/src/application/database-yjs/cell.type.ts +++ b/src/application/database-yjs/cell.type.ts @@ -1,9 +1,10 @@ -import { FieldId, RowId } from '@/application/types'; -import { DateFormat, TimeFormat } from '@/application/database-yjs/index'; -import { FieldType } from '@/application/database-yjs/database.type'; import React from 'react'; import * as Y from 'yjs'; +import { FieldType } from '@/application/database-yjs/database.type'; +import { DateFormat, TimeFormat } from '@/application/database-yjs/index'; +import { FieldId, RowId } from '@/application/types'; + export interface Cell { createdAt: number; lastModified: number; @@ -16,6 +17,11 @@ export interface TextCell extends Cell { data: string; } +export interface AICell extends Cell { + fieldType: FieldType.AISummaries | FieldType.AITranslations; + data: string; +} + export interface NumberCell extends Cell { fieldType: FieldType.Number; data: string; @@ -23,7 +29,7 @@ export interface NumberCell extends Cell { export interface CheckboxCell extends Cell { fieldType: FieldType.Checkbox; - data: boolean; + data: string; // 'Yes' | 'No' | '1' | '0' | 'true' | 'false' } export interface UrlCell extends Cell { @@ -53,15 +59,22 @@ export interface DateTimeCell extends Cell { } export enum FileMediaType { - Image = 'Image', - Video = 'Video', - Link = 'Link', - Other = 'Other', + Image = 1, + Video = 5, + Link = 2, + Other = 0, + Audio = 6, + // Eg. pdf, doc, etc. + Document = 3, + // Eg. zip, rar, etc. + Archive = 4, + // Eg. txt, csv, etc. + Text = 7, } export enum FileMediaUploadType { - CloudMedia = 'CloudMedia', - NetworkMedia = 'NetworkMedia', + CloudMedia = 2, + NetworkMedia = 1, } export interface FileMediaCellDataItem { @@ -72,24 +85,13 @@ export interface FileMediaCellDataItem { url: string; } -export type FileMediaCellData = FileMediaCellDataItem[] +export type FileMediaCellData = FileMediaCellDataItem[]; export interface FileMediaCell extends Cell { fieldType: FieldType.FileMedia; data: FileMediaCellData; } -export interface DateTimeCellData { - date?: string; - time?: string; - timestamp?: number; - includeTime?: boolean; - endDate?: string; - endTime?: string; - endTimestamp?: number; - isRange?: boolean; -} - export interface ChecklistCell extends Cell { fieldType: FieldType.Checklist; data: string; @@ -97,7 +99,7 @@ export interface ChecklistCell extends Cell { export interface RelationCell extends Cell { fieldType: FieldType.Relation; - data: Y.Array; + data: Y.Array; } export type RelationCellData = RowId[]; @@ -110,4 +112,8 @@ export interface CellProps { readOnly?: boolean; placeholder?: string; className?: string; + editing?: boolean; + setEditing?: (editing: boolean) => void; + isHovering?: boolean; + wrap: boolean; } diff --git a/src/application/database-yjs/const.ts b/src/application/database-yjs/const.ts index 12deaaa2..6c01af2e 100644 --- a/src/application/database-yjs/const.ts +++ b/src/application/database-yjs/const.ts @@ -4,6 +4,7 @@ import { v5 as uuidv5, parse as uuidParse } from 'uuid'; export const DEFAULT_ROW_HEIGHT = 36; export const MIN_COLUMN_WIDTH = 150; +export const PADDING_END = 220; export const getCell = (rowId: string, fieldId: string, rowMetas: Record) => { const rowMeta = rowMetas[rowId]; diff --git a/src/application/database-yjs/context.ts b/src/application/database-yjs/context.ts index 56e3fb31..a8d109ae 100644 --- a/src/application/database-yjs/context.ts +++ b/src/application/database-yjs/context.ts @@ -1,14 +1,24 @@ +import { AxiosInstance } from 'axios'; +import { createContext, useContext } from 'react'; + import { + CreateFolderViewPayload, CreateRowDoc, + DatabaseRelations, + GenerateAISummaryRowPayload, + GenerateAITranslateRowPayload, LoadView, - LoadViewMeta, RowId, + LoadViewMeta, + RowId, + UpdatePagePayload, + View, YDatabase, YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey, + YSharedRoot, } from '@/application/types'; -import { createContext, useContext } from 'react'; export interface DatabaseContextState { readOnly: boolean; @@ -17,16 +27,28 @@ export interface DatabaseContextState { viewId: string; rowDocMap: Record | null; isDatabaseRowPage?: boolean; - scrollLeft?: number; + paddingStart?: number; + paddingEnd?: number; isDocumentBlock?: boolean; - navigateToRow?: (rowId: string) => void; + // use different view id to navigate to row + navigateToRow?: (rowId: string, viewId?: string) => void; loadView?: LoadView; createRowDoc?: CreateRowDoc; loadViewMeta?: LoadViewMeta; navigateToView?: (viewId: string, blockId?: string) => Promise; - onRendered?: (height: number) => void; + onRendered?: () => void; showActions?: boolean; workspaceId: string; + createFolderView?: (payload: CreateFolderViewPayload) => Promise; + updatePage?: (viewId: string, payload: UpdatePagePayload) => Promise; + deletePage?: (viewId: string) => Promise; + generateAISummaryForRow?: (payload: GenerateAISummaryRowPayload) => Promise; + generateAITranslateForRow?: (payload: GenerateAITranslateRowPayload) => Promise; + loadDatabaseRelations?: () => Promise; + loadViews?: () => Promise; + uploadFile?: (file: File) => Promise; + createOrphanedView?: (payload: { document_id: string }) => Promise; + requestInstance?: AxiosInstance | null; } export const DatabaseContext = createContext(null); @@ -34,13 +56,27 @@ export const DatabaseContext = createContext(null); export const useDatabaseContext = () => { const context = useContext(DatabaseContext); - if(!context) { + if (!context) { throw new Error('DatabaseContext is not provided'); } return context; }; +export const useDocGuid = () => { + return useDatabaseContext().databaseDoc.guid; +}; + +export const useSharedRoot = () => { + return useDatabaseContext().databaseDoc?.getMap(YjsEditorKey.data_section) as YSharedRoot; +}; + +export const useCreateRow = () => { + const context = useDatabaseContext(); + + return context.createRowDoc; +}; + export const useDatabase = () => { const database = useDatabaseContext() .databaseDoc?.getMap(YjsEditorKey.data_section) @@ -80,7 +116,7 @@ export const useDatabaseViewId = () => { export const useReadOnly = () => { const context = useDatabaseContext(); - return context?.readOnly || true; + return context?.readOnly === undefined ? true : context?.readOnly; }; export const useDatabaseView = () => { @@ -100,4 +136,4 @@ export const useDatabaseSelectedView = (viewId: string) => { const database = useDatabase(); return database.get(YjsDatabaseKey.views).get(viewId); -}; \ No newline at end of file +}; diff --git a/src/application/database-yjs/database.type.ts b/src/application/database-yjs/database.type.ts index c73ceb7b..69cac31b 100644 --- a/src/application/database-yjs/database.type.ts +++ b/src/application/database-yjs/database.type.ts @@ -1,4 +1,4 @@ -import { FieldId } from '@/application/types'; +import { FieldId, RowCoverType } from '@/application/types'; export enum FieldVisibility { AlwaysShown = 0, @@ -73,3 +73,33 @@ export enum RowMetaKey { CoverId = 'cover_id', IsDocumentEmpty = 'is_document_empty', } + +export interface RowMeta { + documentId: string; + cover: { + data: string, + cover_type: RowCoverType, + } | null; + icon: string; + isEmptyDocument: boolean; +} + +export enum AITranslateLanguage { + Traditional_Chinese, + English, + French, + German, + Hindi, + Spanish, + Portuguese, + Standard_Arabic, + Simplified_Chinese +} + +export enum DateGroupCondition { + Relative = 0, + Day = 1, + Week = 2, + Month = 3, + Year = 4 +} \ No newline at end of file diff --git a/src/application/database-yjs/dispatch.ts b/src/application/database-yjs/dispatch.ts new file mode 100644 index 00000000..d85b1b2a --- /dev/null +++ b/src/application/database-yjs/dispatch.ts @@ -0,0 +1,3493 @@ +import dayjs from 'dayjs'; +import { countBy } from 'lodash-es'; +import { nanoid } from 'nanoid'; +import { useCallback, useMemo } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import * as Y from 'yjs'; + +import { parseYDatabaseDateTimeCellToCell } from '@/application/database-yjs/cell.parse'; +import { + useCreateRow, + useDatabase, + useDatabaseContext, + useDatabaseFields, + useDatabaseView, + useDatabaseViewId, + useDocGuid, + useRowDocMap, + useSharedRoot, +} from '@/application/database-yjs/context'; +import { + AITranslateLanguage, + CalculationType, + DateGroupCondition, + FieldType, + FieldVisibility, + FilterType, + RowMetaKey, + SortCondition, +} from '@/application/database-yjs/database.type'; +import { + DateFormat, + getDateCellStr, + getFieldName, + isDate, + NumberFormat, + parseSelectOptionTypeOptions, + RIGHTWARDS_ARROW, + safeParseTimestamp, + SelectOption, + SelectTypeOption, + TimeFormat, +} from '@/application/database-yjs/fields'; +import { createCheckboxCell, getChecked } from '@/application/database-yjs/fields/checkbox/utils'; +import { EnhancedBigStats } from '@/application/database-yjs/fields/number/EnhancedBigStats'; +import { createSelectOptionCell, getColorByOption } from '@/application/database-yjs/fields/select-option/utils'; +import { createTextField } from '@/application/database-yjs/fields/text/utils'; +import { dateFilterFillData, filterFillData, getDefaultFilterCondition } from '@/application/database-yjs/filter'; +import { getOptionsFromRow, initialDatabaseRow } from '@/application/database-yjs/row'; +import { generateRowMeta, getMetaIdMap, getMetaJSON } from '@/application/database-yjs/row_meta'; +import { useBoardLayoutSettings, useFieldSelector, useFieldType } from '@/application/database-yjs/selector'; +import { executeOperations } from '@/application/slate-yjs/utils/yjs'; +import { + DatabaseViewLayout, + FieldId, + RowId, + UpdatePagePayload, + ViewLayout, + YDatabase, + YDatabaseBoardLayoutSetting, + YDatabaseCalculation, + YDatabaseCalculations, + YDatabaseCell, + YDatabaseField, + YDatabaseFieldOrders, + YDatabaseFieldSetting, + YDatabaseFieldSettings, + YDatabaseFieldTypeOption, + YDatabaseFilter, + YDatabaseFilters, + YDatabaseGroup, + YDatabaseGroupColumns, + YDatabaseGroups, + YDatabaseLayoutSettings, + YDatabaseRow, + YDatabaseRowOrders, + YDatabaseSort, + YDatabaseSorts, + YDatabaseView, + YjsDatabaseKey, + YjsEditorKey, + YMapFieldTypeOption, + YSharedRoot, +} from '@/application/types'; + +export function useResizeColumnWidthDispatch() { + const database = useDatabase(); + const viewId = useDatabaseViewId(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (fieldId: string, width: number) => { + executeOperations( + sharedRoot, + [ + () => { + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + const fields = database?.get(YjsDatabaseKey.fields); + const fieldSettings = view?.get(YjsDatabaseKey.field_settings); + const field = fields?.get(fieldId); + let fieldSetting = fieldSettings?.get(fieldId); + + if (!field || !fieldSettings) return; + + if (!fieldSetting) { + fieldSetting = new Y.Map() as YDatabaseFieldSetting; + fieldSettings.set(fieldId, fieldSetting); + } + + const currentWidth = fieldSetting.get(YjsDatabaseKey.width); + + if (Number(currentWidth) === width) return; + + fieldSetting.set(YjsDatabaseKey.width, String(width)); + }, + ], + 'resizeColumnWidth' + ); + }, + [database, sharedRoot, viewId] + ); +} + +export function useReorderColumnDispatch() { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (columnId: string, beforeColumnId?: string) => { + executeOperations( + sharedRoot, + [ + () => { + const fields = view?.get(YjsDatabaseKey.field_orders); + + if (!fields) { + throw new Error(`Fields order not found`); + } + + const columnArray = fields.toJSON() as { + id: string; + }[]; + + const originalIndex = columnArray.findIndex((column) => column.id === columnId); + const targetIndex = + beforeColumnId === undefined ? 0 : columnArray.findIndex((column) => column.id === beforeColumnId) + 1; + + const column = fields.get(originalIndex); + + let adjustedTargetIndex = targetIndex; + + if (targetIndex > originalIndex) { + adjustedTargetIndex -= 1; + } + + fields.delete(originalIndex); + + fields.insert(adjustedTargetIndex, [column]); + }, + ], + 'reorderColumn' + ); + }, + [sharedRoot, view] + ); +} + +function generateGroupByField(field: YDatabaseField) { + const group = new Y.Map() as YDatabaseGroup; + + const fieldId = field.get(YjsDatabaseKey.id); + const fieldType = Number(field.get(YjsDatabaseKey.type)) as FieldType; + + const columns = new Y.Array() as YDatabaseGroupColumns; + + group.set(YjsDatabaseKey.field_id, fieldId); + group.set(YjsDatabaseKey.id, `g:${nanoid(6)}`); + + switch (fieldType) { + case FieldType.SingleSelect: + case FieldType.MultiSelect: { + group.set(YjsDatabaseKey.content, ''); + const typeOption = parseSelectOptionTypeOptions(field); + const options = typeOption?.options || []; + + columns.push([{ id: fieldId, visible: true }]); + + // Add a column for each option + options.forEach((option) => { + columns.push([{ id: option.id, visible: true }]); + }); + break; + } + + case FieldType.Checkbox: + group.set(YjsDatabaseKey.content, ''); + // Add a column for the checkbox field + columns.push([{ id: 'Yes', visible: true }]); + columns.push([{ id: 'No', visible: true }]); + break; + case FieldType.DateTime: + case FieldType.CreatedTime: + case FieldType.LastEditedTime: + group.set( + YjsDatabaseKey.content, + JSON.stringify({ + hide_empty: false, + condition: DateGroupCondition.Relative, + }) + ); + + columns.push([{ id: fieldId, visible: true }]); + break; + default: + break; + } + + group.set(YjsDatabaseKey.groups, columns); + + return group; +} + +export function useGroupByFieldDispatch() { + const view = useDatabaseView(); + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + const { fieldId: currentFieldId } = useBoardLayoutSettings(); + + return useCallback( + (fieldId: string) => { + if (!view) { + throw new Error('View not found'); + } + + if (currentFieldId && currentFieldId === fieldId) { + // If the field is already grouped, do nothing + return; + } + + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); + + if (!field) { + throw new Error(`Field with id ${fieldId} not found`); + } + + executeOperations( + sharedRoot, + [ + () => { + // Remove the filter for the field if it will be grouped + const filters = view.get(YjsDatabaseKey.filters); + const filterIndex = filters + ?.toArray() + .findIndex((filter) => filter.get(YjsDatabaseKey.field_id) === fieldId); + + if (filters && filterIndex > -1) { + filters?.delete(filterIndex); + } + + let groups = view.get(YjsDatabaseKey.groups); + + if (!groups) { + groups = new Y.Array() as YDatabaseGroups; + view.set(YjsDatabaseKey.groups, groups); + } + + const group = generateGroupByField(field); + + // Only one group can exist at a time, so we clear the existing groups + groups.delete(0, groups.length); + groups.insert(0, [group]); + }, + ], + 'groupByField' + ); + }, + [currentFieldId, database, sharedRoot, view] + ); +} + +export function useReorderGroupColumnDispatch(groupId: string) { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (columnId: string, beforeColumnId?: string) => { + executeOperations( + sharedRoot, + [ + () => { + const group = view + ?.get(YjsDatabaseKey.groups) + ?.toArray() + .find((group) => group.get(YjsDatabaseKey.id) === groupId); + const groupColumns = group?.get(YjsDatabaseKey.groups); + + if (!groupColumns) { + throw new Error('Group order not found'); + } + + const columnArray = groupColumns.toJSON() as { + id: string; + }[]; + + const originalIndex = columnArray.findIndex((column) => column.id === columnId); + const targetIndex = + beforeColumnId === undefined ? 0 : columnArray.findIndex((column) => column.id === beforeColumnId) + 1; + + const column = groupColumns.get(originalIndex); + + let adjustedTargetIndex = targetIndex; + + if (targetIndex > originalIndex) { + adjustedTargetIndex -= 1; + } + + groupColumns.delete(originalIndex); + + groupColumns.insert(adjustedTargetIndex, [column]); + }, + ], + 'reorderGroupColumn' + ); + }, + [groupId, sharedRoot, view] + ); +} + +export function useDeleteGroupColumnDispatch(groupId: string, columnId: string, fieldId: string) { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + const deleteRows = useBulkDeleteRowDispatch(); + const deleteSelectOption = useDeleteSelectOption(fieldId); + const fieldType = useFieldType(fieldId); + const deleteGroupColumn = useCallback(() => { + executeOperations( + sharedRoot, + [ + () => { + const groups = view?.get(YjsDatabaseKey.groups); + + if (!groups) { + throw new Error('Groups not found'); + } + + const group = groups.toArray().find((group) => group.get(YjsDatabaseKey.id) === groupId); + + const columns = group?.get(YjsDatabaseKey.groups) as YDatabaseGroupColumns; + + if (!columns) { + throw new Error('Group columns not found'); + } + + const columnArray = columns.toJSON() as { + id: string; + }[]; + + const index = columnArray.findIndex((column) => column.id === columnId); + + if (index === -1) { + throw new Error(`Column with id ${columnId} not found in group ${groupId}`); + } + + columns.delete(index); + }, + ], + 'deleteGroupColumn' + ); + }, [groupId, columnId, sharedRoot, view]); + + const isSelectField = useMemo(() => { + return [FieldType.SingleSelect, FieldType.MultiSelect].includes(fieldType); + }, [fieldType]); + + return useCallback( + (rowIds?: string[]) => { + if (isSelectField) { + // Delete the group column + deleteGroupColumn(); + + // Delete the select option if it exists + deleteSelectOption(columnId); + } + + // If rowIds are provided, delete the rows + if (rowIds && rowIds.length > 0) { + deleteRows(rowIds); + } + }, + [isSelectField, deleteGroupColumn, deleteSelectOption, columnId, deleteRows] + ); +} + +export function useToggleHiddenGroupColumnDispatch(groupId: string, fieldId: string) { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + const layoutSetting = view?.get(YjsDatabaseKey.layout_settings)?.get('1'); + + return useCallback( + (columnId: string, hidden: boolean) => { + executeOperations( + sharedRoot, + [ + () => { + const groups = view?.get(YjsDatabaseKey.groups); + + if (!groups) { + throw new Error('Groups not found'); + } + + const group = groups.toArray().find((group) => group.get(YjsDatabaseKey.id) === groupId); + + if (!group) { + throw new Error(`Group with id ${groupId} not found`); + } + + const columns = group.get(YjsDatabaseKey.groups); + + if (!columns) { + throw new Error('Group columns not found'); + } + + const index = columns.toArray().findIndex((column) => column.id === columnId); + const column = columns.toArray().find((column) => column.id === columnId); + + if (index === -1 || !column) { + throw new Error(`Column with id ${columnId} not found in group ${groupId}`); + } + + const newColumn = { + ...column, + visible: !hidden, + }; + + columns.delete(index); + + columns.insert(index, [newColumn]); + + if (column.id === fieldId && layoutSetting) { + layoutSetting.set(YjsDatabaseKey.hide_ungrouped_column, hidden); + } + }, + ], + 'hideGroupColumn' + ); + }, + [fieldId, groupId, layoutSetting, sharedRoot, view] + ); +} + +export function useToggleCollapsedHiddenGroupColumnDispatch() { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (collapsed: boolean) => { + executeOperations( + sharedRoot, + [ + () => { + if (!view) { + throw new Error(`Unable to toggle collapsed hidden group column`); + } + + // Get or create the layout settings for the view + let layoutSettings = view.get(YjsDatabaseKey.layout_settings); + + if (!layoutSettings) { + layoutSettings = new Y.Map() as YDatabaseLayoutSettings; + } + + let layoutSetting = layoutSettings.get('1'); + + if (!layoutSetting) { + layoutSetting = new Y.Map() as YDatabaseBoardLayoutSetting; + layoutSettings.set('1', layoutSetting); + } + + layoutSetting.set(YjsDatabaseKey.collapse_hidden_groups, collapsed); + }, + ], + 'toggleCollapsedHiddenGroupColumn' + ); + }, + [sharedRoot, view] + ); +} + +export function useToggleHideUnGrouped() { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (hide: boolean) => { + executeOperations( + sharedRoot, + [ + () => { + if (!view) { + throw new Error(`Unable to toggle hide ungrouped column`); + } + + // Get or create the layout settings for the view + let layoutSettings = view.get(YjsDatabaseKey.layout_settings); + + if (!layoutSettings) { + layoutSettings = new Y.Map() as YDatabaseLayoutSettings; + } + + let layoutSetting = layoutSettings.get('1'); + + if (!layoutSetting) { + layoutSetting = new Y.Map() as YDatabaseBoardLayoutSetting; + layoutSettings.set('1', layoutSetting); + } + + layoutSetting.set(YjsDatabaseKey.hide_ungrouped_column, hide); + }, + ], + 'toggleHideUnGrouped' + ); + }, + [sharedRoot, view] + ); +} + +function reorderRow(rowId: string, beforeRowId: string | undefined, view: YDatabaseView) { + const rows = view.get(YjsDatabaseKey.row_orders); + + if (!rows) { + throw new Error('Row orders not found'); + } + + const rowArray = rows.toJSON() as { + id: string; + }[]; + + const sourceIndex = rowArray.findIndex((row) => row.id === rowId); + const targetIndex = beforeRowId !== undefined ? rowArray.findIndex((row) => row.id === beforeRowId) + 1 : 0; + + const row = rows.get(sourceIndex); + + rows.delete(sourceIndex); + + let adjustedTargetIndex = targetIndex; + + if (targetIndex > sourceIndex) { + adjustedTargetIndex -= 1; + } + + rows.insert(adjustedTargetIndex, [row]); +} + +export function useReorderRowDispatch() { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (rowId: string, beforeRowId?: string) => { + executeOperations( + sharedRoot, + [ + () => { + if (!view) { + throw new Error(`Unable to reorder card`); + } + + reorderRow(rowId, beforeRowId, view); + }, + ], + 'reorderRow' + ); + }, + [view, sharedRoot] + ); +} + +export function useMoveCardDispatch() { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + const rowMap = useRowDocMap(); + const database = useDatabase(); + + return useCallback( + ({ + rowId, + beforeRowId, + fieldId, + startColumnId, + finishColumnId, + }: { + rowId: string; + beforeRowId?: string; + fieldId: string; + startColumnId: string; + finishColumnId: string; + }) => { + executeOperations( + sharedRoot, + [ + () => { + if (!view) { + throw new Error(`Unable to reorder card`); + } + + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); + + const fieldType = Number(field.get(YjsDatabaseKey.type)); + + const rowDoc = rowMap?.[rowId]; + + if (!rowDoc) { + throw new Error(`Unable to reorder card`); + } + + const row = rowDoc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database_row) as YDatabaseRow; + + const cells = row.get(YjsDatabaseKey.cells); + const isSelectOptionField = [FieldType.SingleSelect, FieldType.MultiSelect].includes(fieldType); + + let cell = cells.get(fieldId); + + if (!cell) { + // if the cell is empty, create a new cell and set data to finishColumnId + if (isSelectOptionField) { + cell = createSelectOptionCell(fieldId, fieldType, finishColumnId); + } else if (fieldType === FieldType.Checkbox) { + cell = createCheckboxCell(fieldId, finishColumnId); + } + + cells.set(fieldId, cell); + } else { + const cellData = cell.get(YjsDatabaseKey.data); + let newCellData = cellData; + + if (isSelectOptionField) { + const selectedIds = (cellData as string)?.split(',') ?? []; + const index = selectedIds.findIndex((id) => id === startColumnId); + + if (selectedIds.includes(finishColumnId)) { + // if the finishColumnId is already in the selectedIds + selectedIds.splice(index, 1); // remove the startColumnId from the selectedIds + } else { + selectedIds.splice(index, 1, finishColumnId); // replace the startColumnId with finishColumnId + } + + newCellData = selectedIds.join(','); + } else if (fieldType === FieldType.Checkbox) { + newCellData = finishColumnId; + } + + cell.set(YjsDatabaseKey.data, newCellData); + } + + reorderRow(rowId, beforeRowId, view); + }, + ], + 'reorderCard' + ); + }, + [database, rowMap, sharedRoot, view] + ); +} + +export function useDeleteRowDispatch() { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (rowId: string) => { + executeOperationWithAllViews( + sharedRoot, + database, + (view) => { + if (!view) { + throw new Error(`Unable to delete row`); + } + + const rows = view.get(YjsDatabaseKey.row_orders); + + const rowArray = rows.toJSON() as { + id: string; + }[]; + + const sourceIndex = rowArray.findIndex((row) => row.id === rowId); + + rows.delete(sourceIndex); + }, + 'deleteRowDispatch' + ); + }, + [sharedRoot, database] + ); +} + +export function useBulkDeleteRowDispatch() { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (rowIds: string[]) => { + executeOperationWithAllViews( + sharedRoot, + database, + (view) => { + if (!view) { + throw new Error(`Unable to bulk delete rows`); + } + + const rows = view.get(YjsDatabaseKey.row_orders); + + rowIds.forEach((rowId) => { + const rowArray = rows.toJSON() as { + id: string; + }[]; + + const sourceIndex = rowArray.findIndex((row) => row.id === rowId); + + // If the row is not found, skip it + if (sourceIndex !== -1) { + rows.delete(sourceIndex); + } + }); + }, + 'bulkDeleteRowDispatch' + ); + }, + [sharedRoot, database] + ); +} + +export function useCalculateFieldDispatch(fieldId: string) { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + const fieldType = useFieldType(fieldId); + + return useCallback( + (cells: Map) => { + const calculations = view?.get(YjsDatabaseKey.calculations); + const index = (calculations?.toArray() || []).findIndex((calculation) => { + return calculation.get(YjsDatabaseKey.field_id) === fieldId; + }); + + if (index === -1 || !calculations) { + return; + } + + const cellValues = Array.from(cells.values()); + + const countEmptyResult = countBy(cellValues, (data) => { + if (fieldType === FieldType.Checkbox) { + if (getChecked(data as string)) { + return CalculationType.CountNonEmpty; + } + + return CalculationType.CountEmpty; + } + + if (fieldType === FieldType.Checklist && typeof data === 'string') { + try { + const { options, selected_option_ids } = JSON.parse(data); + const percentage = selected_option_ids.length / options.length; + + if (percentage === 1) { + return CalculationType.CountNonEmpty; + } + + return CalculationType.CountEmpty; + } catch (e) { + // do nothing, return empty + } + } + + if (!data) { + return CalculationType.CountEmpty; + } else { + return CalculationType.CountNonEmpty; + } + }); + + const itemMap = (data: unknown) => { + if (typeof data === 'number') { + return data.toString(); + } + + if (typeof data === 'string') { + return EnhancedBigStats.parse(data); + } + + return null; + }; + + const nums = cellValues.map(itemMap).filter((item) => !!item) as string[]; + const stats = new EnhancedBigStats(nums); + + const getSum = () => { + return stats.sum().toString(); + }; + + const getAverage = () => { + return stats.average().toString(); + }; + + const getMedian = () => { + return stats.median().toString(); + }; + + const getMin = () => { + return stats.min().toString(); + }; + + const getMax = () => { + return stats.max().toString(); + }; + + const item = calculations.get(index); + const type = Number(item.get(YjsDatabaseKey.type)) as CalculationType; + const oldValue = item.get(YjsDatabaseKey.calculation_value) as string | number; + + let newValue = oldValue; + + switch (type) { + case CalculationType.CountEmpty: + newValue = countEmptyResult[CalculationType.CountEmpty]; + break; + case CalculationType.CountNonEmpty: + newValue = countEmptyResult[CalculationType.CountNonEmpty]; + break; + case CalculationType.Count: + newValue = cellValues.length; + + break; + case CalculationType.Sum: + newValue = getSum(); + break; + case CalculationType.Average: + newValue = getAverage(); + break; + case CalculationType.Median: + newValue = getMedian(); + break; + case CalculationType.Max: + newValue = getMax(); + break; + case CalculationType.Min: + newValue = getMin(); + break; + default: + break; + } + + if (newValue !== oldValue) { + executeOperations( + sharedRoot, + [ + () => { + item.set(YjsDatabaseKey.calculation_value, newValue); + }, + ], + 'calculateFieldDispatch' + ); + } + }, + [view, fieldId, fieldType, sharedRoot] + ); +} + +export function useUpdateCalculate(fieldId: string) { + const sharedRoot = useSharedRoot(); + const view = useDatabaseView(); + + return useCallback( + (type: CalculationType) => { + if (!view) return; + executeOperations( + sharedRoot, + [ + () => { + let calculations = view?.get(YjsDatabaseKey.calculations); + + if (!calculations) { + calculations = new Y.Array() as YDatabaseCalculations; + view.set(YjsDatabaseKey.calculations, calculations); + } + + let item = calculations.toArray().find((calculation) => { + return calculation.get(YjsDatabaseKey.field_id) === fieldId; + }); + + if (!item) { + item = new Y.Map() as YDatabaseCalculation; + item.set(YjsDatabaseKey.id, nanoid(6)); + item.set(YjsDatabaseKey.field_id, fieldId); + calculations.push([item]); + } + + item.set(YjsDatabaseKey.type, type); + }, + ], + 'updateCalculate' + ); + }, + [fieldId, sharedRoot, view] + ); +} + +export function useClearCalculate(fieldId: string) { + const sharedRoot = useSharedRoot(); + const view = useDatabaseView(); + + return useCallback(() => { + executeOperations( + sharedRoot, + [ + () => { + const calculations = view?.get(YjsDatabaseKey.calculations); + + if (!calculations) { + throw new Error(`Calculations not found`); + } + + const index = calculations.toArray().findIndex((calculation) => { + return calculation.get(YjsDatabaseKey.field_id) === fieldId; + }); + + if (index !== -1) { + calculations.delete(index); + } + }, + ], + 'clearCalculate' + ); + }, [fieldId, sharedRoot, view]); +} + +export function useUpdatePropertyNameDispatch(fieldId: string) { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (name: string) => { + executeOperations( + sharedRoot, + [ + () => { + const field = database?.get(YjsDatabaseKey.fields)?.get(fieldId); + + if (!field) { + throw new Error(`Field not found`); + } + + field.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + + field.set(YjsDatabaseKey.name, name); + }, + ], + 'updatePropertyName' + ); + }, + [database, fieldId, sharedRoot] + ); +} + +function createField(type: FieldType, fieldId: string) { + switch (type) { + case FieldType.RichText: + return createTextField(fieldId); + default: + throw new Error(`Field type ${type} not supported`); + } +} + +export function useNewPropertyDispatch() { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (fieldType: FieldType) => { + const fieldId = nanoid(6); + + executeOperationWithAllViews( + sharedRoot, + database, + (view) => { + const fields = database?.get(YjsDatabaseKey.fields); + const fieldOrders = view?.get(YjsDatabaseKey.field_orders); + + if (!fields || !fieldOrders) { + throw new Error(`Field not found`); + } + + const field: YDatabaseField = createField(fieldType, fieldId); + + fields.set(fieldId, field); + + fieldOrders.push([ + { + id: fieldId, + }, + ]); + }, + 'newPropertyDispatch' + ); + + return fieldId; + }, + [database, sharedRoot] + ); +} + +export function useAddPropertyLeftDispatch() { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (fieldId: string) => { + const newId = nanoid(6); + + executeOperationWithAllViews( + sharedRoot, + database, + (view) => { + const fields = database?.get(YjsDatabaseKey.fields); + const fieldOrders = view?.get(YjsDatabaseKey.field_orders); + + if (!fields || !fieldOrders) { + throw new Error(`Field not found`); + } + + const field: YDatabaseField = createField(FieldType.RichText, newId); + + fields.set(newId, field); + + const index = fieldOrders.toArray().findIndex((field) => field.id === fieldId); + + if (index !== -1) { + fieldOrders.insert(index, [ + { + id: newId, + }, + ]); + } + }, + 'addPropertyLeftDispatch' + ); + return newId; + }, + [database, sharedRoot] + ); +} + +export function useAddPropertyRightDispatch() { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (fieldId: string) => { + const newId = nanoid(6); + + executeOperationWithAllViews( + sharedRoot, + database, + (view) => { + const fields = database?.get(YjsDatabaseKey.fields); + const fieldOrders = view?.get(YjsDatabaseKey.field_orders); + + if (!fields || !fieldOrders) { + throw new Error(`Field not found`); + } + + const field: YDatabaseField = createField(FieldType.RichText, newId); + + fields.set(newId, field); + + const index = fieldOrders.toArray().findIndex((field) => field.id === fieldId); + + if (index !== -1) { + fieldOrders.insert(index + 1, [ + { + id: newId, + }, + ]); + } + }, + 'addPropertyRightDispatch' + ); + return newId; + }, + [database, sharedRoot] + ); +} + +function executeOperationWithAllViews( + sharedRoot: YSharedRoot, + database: YDatabase, + operation: (view: YDatabaseView, viewId: string) => void, + operationName: string +) { + const views = database.get(YjsDatabaseKey.views); + const viewIds = Object.keys(views.toJSON()); + + executeOperations( + sharedRoot, + [ + () => { + viewIds.forEach((viewId) => { + const view = database.get(YjsDatabaseKey.views)?.get(viewId); + + if (!view) { + throw new Error(`View not found`); + } + + try { + operation(view, viewId); + } catch (e) { + // do nothing + } + }); + }, + ], + operationName + ); +} + +export function useDeletePropertyDispatch() { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (fieldId: string) => { + executeOperationWithAllViews( + sharedRoot, + database, + (view) => { + const fields = database.get(YjsDatabaseKey.fields); + const fieldOrders = view.get(YjsDatabaseKey.field_orders); + const filters = view.get(YjsDatabaseKey.filters); + const sorts = view.get(YjsDatabaseKey.sorts); + + if (!fields || !fieldOrders) { + throw new Error(`Field not found`); + } + + if (filters) { + const index = filters.toArray().findIndex((filter) => filter.get(YjsDatabaseKey.field_id) === fieldId); + + if (index !== -1) { + filters.delete(index); + } + } + + if (sorts) { + const index = sorts.toArray().findIndex((sort) => sort.get(YjsDatabaseKey.field_id) === fieldId); + + if (index !== -1) { + sorts.delete(index); + } + } + + fields.delete(fieldId); + + const index = fieldOrders.toArray().findIndex((field) => field.id === fieldId); + + if (index !== -1) { + fieldOrders.delete(index); + } + }, + 'deletePropertyDispatch' + ); + }, + [database, sharedRoot] + ); +} + +export function useNewRowDispatch() { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + const createRow = useCreateRow(); + const guid = useDocGuid(); + const viewId = useDatabaseViewId(); + const currentView = useDatabaseView(); + const filters = currentView?.get(YjsDatabaseKey.filters); + const { navigateToRow } = useDatabaseContext(); + + return useCallback( + async ({ + beforeRowId, + cellsData, + tailing = false, + }: { + beforeRowId?: string; + cellsData?: Record; + tailing?: boolean; + }) => { + if (!createRow) { + throw new Error('No createRow function'); + } + + const rowId = uuidv4(); + + const rowDoc = await createRow(`${guid}_rows_${rowId}`); + + rowDoc.transact(() => { + initialDatabaseRow(rowId, database.get(YjsDatabaseKey.id), rowDoc); + const rowSharedRoot = rowDoc.getMap(YjsEditorKey.data_section) as YSharedRoot; + const row = rowSharedRoot.get(YjsEditorKey.database_row); + + const cells = row.get(YjsDatabaseKey.cells); + + let shouldOpenRowModal = false; + + if (filters) { + filters.toArray().forEach((filter) => { + const cell = new Y.Map() as YDatabaseCell; + const fieldId = filter.get(YjsDatabaseKey.field_id); + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); + + if (!field) { + return; + } + + const type = Number(field.get(YjsDatabaseKey.type)); + + if (type === FieldType.DateTime) { + const { data, endTimestamp, isRange } = dateFilterFillData(filter); + + if (data !== null) { + cell.set(YjsDatabaseKey.data, data); + } + + if (endTimestamp) { + cell.set(YjsDatabaseKey.end_timestamp, endTimestamp); + } + + if (isRange) { + cell.set(YjsDatabaseKey.is_range, isRange); + } + } else if ([FieldType.CreatedTime, FieldType.LastEditedTime].includes(type)) { + shouldOpenRowModal = true; + return; + } else { + const data = filterFillData(filter, field); + + if (data === null) { + return; + } + + cell.set(YjsDatabaseKey.data, data); + } + + cell.set(YjsDatabaseKey.created_at, String(dayjs().unix())); + cell.set(YjsDatabaseKey.field_type, type); + + cells.set(fieldId, cell); + }); + } + + if (cellsData) { + Object.entries(cellsData).forEach(([fieldId, data]) => { + const cell = new Y.Map() as YDatabaseCell; + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); + + const type = Number(field.get(YjsDatabaseKey.type)); + + cell.set(YjsDatabaseKey.created_at, String(dayjs().unix())); + cell.set(YjsDatabaseKey.field_type, type); + + cell.set(YjsDatabaseKey.data, data); + + cells.set(fieldId, cell); + }); + } + + row.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + + if (shouldOpenRowModal) { + navigateToRow?.(rowId); + } + }); + + executeOperationWithAllViews( + sharedRoot, + database, + (view, id) => { + const rowOrders = view.get(YjsDatabaseKey.row_orders); + + if (!rowOrders) { + throw new Error(`Row orders not found`); + } + + const row = { + id: rowId, + height: 36, + }; + + const index = beforeRowId ? rowOrders.toArray().findIndex((row) => row.id === beforeRowId) + 1 : 0; + + if ((viewId !== id && index === -1) || tailing) { + rowOrders.push([row]); + } else { + rowOrders.insert(index, [row]); + } + }, + 'newRowDispatch' + ); + + return rowId; + }, + [createRow, database, filters, guid, navigateToRow, sharedRoot, viewId] + ); +} + +function cloneCell(fieldType: FieldType, referenceCell?: YDatabaseCell) { + const cell = new Y.Map() as YDatabaseCell; + + referenceCell?.forEach((value, key) => { + let newValue = value; + + if (typeof value === 'bigint') { + newValue = value.toString(); + } else if (value && value instanceof Y.Array) { + return; + } + + cell.set(key, newValue); + }); + + let data = referenceCell?.get(YjsDatabaseKey.data); + + if (fieldType === FieldType.Relation && data) { + const newData = new Y.Array(); + const referenceData = data as Y.Array; + + referenceData.toArray().forEach((rowId) => { + newData.push([rowId]); + }); + data = newData; + } + + if (fieldType === FieldType.FileMedia) { + const newData = new Y.Array(); + const referenceData = data as Y.Array; + + referenceData.toArray().forEach((file) => { + newData.push([file]); + }); + data = newData; + } + + if (referenceCell) { + cell.set(YjsDatabaseKey.data, data); + } + + cell.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + cell.set(YjsDatabaseKey.created_at, String(dayjs().unix())); + cell.set(YjsDatabaseKey.field_type, fieldType); + + return cell; +} + +export function useDuplicateRowDispatch() { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + const createRow = useCreateRow(); + const guid = useDocGuid(); + const rowDocMap = useRowDocMap(); + + return useCallback( + async (referenceRowId: string) => { + const referenceRowDoc = rowDocMap?.[referenceRowId]; + + if (!referenceRowDoc) { + throw new Error(`Row not found`); + } + + if (!createRow) { + throw new Error('No createRow function'); + } + + const referenceRowSharedRoot = referenceRowDoc.getMap(YjsEditorKey.data_section) as YSharedRoot; + const referenceRow = referenceRowSharedRoot.get(YjsEditorKey.database_row); + const referenceCells = referenceRow.get(YjsDatabaseKey.cells); + const referenceMeta = getMetaJSON(referenceRowId, referenceRowSharedRoot.get(YjsEditorKey.meta)); + + const rowId = uuidv4(); + + const icon = referenceMeta.icon; + const cover = referenceMeta.cover; + + const newMeta = generateRowMeta(rowId, { + [RowMetaKey.IsDocumentEmpty]: true, + [RowMetaKey.IconId]: icon, + [RowMetaKey.CoverId]: cover ? JSON.stringify(cover) : null, + }); + + const rowDoc = await createRow(`${guid}_rows_${rowId}`); + + rowDoc.transact(() => { + initialDatabaseRow(rowId, database.get(YjsDatabaseKey.id), rowDoc); + + const rowSharedRoot = rowDoc.getMap(YjsEditorKey.data_section) as YSharedRoot; + + const row = rowSharedRoot.get(YjsEditorKey.database_row); + + const meta = rowSharedRoot.get(YjsEditorKey.meta); + + Object.keys(newMeta).forEach((key) => { + const value = newMeta[key]; + + if (value) { + meta.set(key, value); + } + }); + + const cells = row.get(YjsDatabaseKey.cells); + + Object.keys(referenceCells.toJSON()).forEach((fieldId) => { + try { + const referenceCell = referenceCells.get(fieldId); + + if (!referenceCell) { + throw new Error(`Cell not found`); + } + + const field = database.get(YjsDatabaseKey.fields); + const fieldType = Number(field.get(fieldId)?.get(YjsDatabaseKey.type)); + + const cell = cloneCell(fieldType, referenceCell); + + cells.set(fieldId, cell); + } catch (e) { + console.error(e); + } + }); + + row.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + }); + + executeOperationWithAllViews( + sharedRoot, + database, + (view) => { + const rowOrders = view.get(YjsDatabaseKey.row_orders); + + if (!rowOrders) { + throw new Error(`Row orders not found`); + } + + const row = { + id: rowId, + height: 36, + }; + + const referenceIndex = rowOrders.toArray().findIndex((row) => row.id === referenceRowId); + const targetIndex = referenceIndex + 1; + + if (targetIndex >= rowOrders.length) { + rowOrders.push([row]); + return; + } + + rowOrders.insert(targetIndex, [row]); + }, + 'duplicateRowDispatch' + ); + + return rowId; + }, + [createRow, database, guid, rowDocMap, sharedRoot] + ); +} + +export function useClearSortingDispatch() { + const sharedRoot = useSharedRoot(); + const view = useDatabaseView(); + + return useCallback(() => { + executeOperations( + sharedRoot, + [ + () => { + const sorting = view?.get(YjsDatabaseKey.sorts); + + if (!sorting) { + throw new Error(`Sorting not found`); + } + + sorting.delete(0, sorting.length); + }, + ], + 'clearSortingDispatch' + ); + }, [sharedRoot, view]); +} + +export function useUpdatePropertyIconDispatch(fieldId: string) { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (iconId: string) => { + executeOperations( + sharedRoot, + [ + () => { + const field = database?.get(YjsDatabaseKey.fields)?.get(fieldId); + + if (!field) { + throw new Error(`Field not found`); + } + + field.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + + field.set(YjsDatabaseKey.icon, iconId); + }, + ], + 'updatePropertyName' + ); + }, + [database, sharedRoot, fieldId] + ); +} + +export function useHidePropertyDispatch() { + const sharedRoot = useSharedRoot(); + const view = useDatabaseView(); + + return useCallback( + (fieldId: string) => { + executeOperations( + sharedRoot, + [ + () => { + const fieldSettings = view?.get(YjsDatabaseKey.field_settings); + + if (!fieldSettings) { + throw new Error(`Field settings not found`); + } + + let setting = fieldSettings?.get(fieldId); + + if (!setting) { + setting = new Y.Map() as YDatabaseFieldSetting; + + fieldSettings.set(fieldId, setting); + } + + setting.set(YjsDatabaseKey.visibility, FieldVisibility.AlwaysHidden); + }, + ], + 'hidePropertyDispatch' + ); + }, + [sharedRoot, view] + ); +} + +export function useTogglePropertyWrapDispatch() { + const sharedRoot = useSharedRoot(); + const view = useDatabaseView(); + + return useCallback( + (fieldId: string, checked?: boolean) => { + executeOperations( + sharedRoot, + [ + () => { + const fieldSettings = view?.get(YjsDatabaseKey.field_settings); + + if (!fieldSettings) { + throw new Error(`Field settings not found`); + } + + let setting = fieldSettings.get(fieldId); + + if (!setting) { + setting = new Y.Map() as YDatabaseFieldSetting; + fieldSettings.set(fieldId, setting); + } + + const wrap = setting.get(YjsDatabaseKey.wrap) ?? true; + + if (checked !== undefined) { + setting.set(YjsDatabaseKey.wrap, checked); + } else { + setting.set(YjsDatabaseKey.wrap, !wrap); + } + }, + ], + 'togglePropertyWrapDispatch' + ); + }, + [sharedRoot, view] + ); +} + +export function useShowPropertyDispatch() { + const sharedRoot = useSharedRoot(); + const view = useDatabaseView(); + + return useCallback( + (fieldId: string) => { + executeOperations( + sharedRoot, + [ + () => { + const fieldSettings = view?.get(YjsDatabaseKey.field_settings); + + const setting = fieldSettings?.get(fieldId); + + if (!setting) { + throw new Error(`Field not found`); + } + + setting.set(YjsDatabaseKey.visibility, FieldVisibility.AlwaysShown); + }, + ], + 'showPropertyDispatch' + ); + }, + [sharedRoot, view] + ); +} + +export function useClearCellsWithFieldDispatch() { + const sharedRoot = useSharedRoot(); + const rowDocs = useRowDocMap(); + + return useCallback( + (fieldId: string) => { + executeOperations( + sharedRoot, + [ + () => { + if (!rowDocs) { + throw new Error(`Row docs not found`); + } + + const rows = Object.keys(rowDocs); + + if (!rows) { + throw new Error(`Row orders not found`); + } + + rows.forEach((rowId) => { + const rowDoc = rowDocs?.[rowId]; + + if (!rowDoc) { + return; + } + + rowDoc.transact(() => { + const rowSharedRoot = rowDoc.getMap(YjsEditorKey.data_section) as YSharedRoot; + const row = rowSharedRoot.get(YjsEditorKey.database_row); + const cells = row.get(YjsDatabaseKey.cells); + + cells.delete(fieldId); + row.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + }); + }); + }, + ], + 'clearCellsWithFieldDispatch' + ); + }, + [rowDocs, sharedRoot] + ); +} + +export function useDuplicatePropertyDispatch() { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + const rowDocs = useRowDocMap(); + + return useCallback( + (fieldId: string) => { + const newId = nanoid(6); + + executeOperations( + sharedRoot, + [ + () => { + const fields = database?.get(YjsDatabaseKey.fields); + + if (!fields) { + throw new Error(`Fields not found`); + } + + const field = fields.get(fieldId); + + if (!field) { + throw new Error(`Field not found`); + } + + // Clone Field + const newField = new Y.Map() as YDatabaseField; + + const fieldType = Number(field.get(YjsDatabaseKey.type)); + + newField.set(YjsDatabaseKey.id, newId); + newField.set(YjsDatabaseKey.name, field.get(YjsDatabaseKey.name) + ' (copy)'); + newField.set(YjsDatabaseKey.type, fieldType); + newField.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + newField.set(YjsDatabaseKey.is_primary, false); + newField.set(YjsDatabaseKey.icon, field.get(YjsDatabaseKey.icon)); + const fieldTypeOptionMap = field.get(YjsDatabaseKey.type_option); + + if (fieldTypeOptionMap) { + const newFieldTypeOptionMap = new Y.Map() as YDatabaseFieldTypeOption; + const fieldTypeOption = fieldTypeOptionMap.get(String(fieldType)); + + if (fieldTypeOption) { + const newFieldTypeOption = new Y.Map() as YMapFieldTypeOption; + + fieldTypeOption.forEach((value, key) => { + // Because rust uses bigint for enum or some other values, so we need to convert it to string + // Yjs cannot set bigint value directly + if (typeof value === 'bigint') { + newFieldTypeOption.set(key, Number(value)); + } else { + newFieldTypeOption.set(key, value); + } + }); + newFieldTypeOptionMap.set(String(fieldType), newFieldTypeOption); + } + + newField.set(YjsDatabaseKey.type_option, newFieldTypeOptionMap); + } + + fields.set(newId, newField); + }, + ], + 'duplicatePropertyDispatch' + ); + + // Insert new field to all views + executeOperationWithAllViews( + sharedRoot, + database, + (view) => { + const fields = database?.get(YjsDatabaseKey.fields); + const fieldOrders = view?.get(YjsDatabaseKey.field_orders); + const fieldSettings = view?.get(YjsDatabaseKey.field_settings); + + if (!fields || !fieldOrders || !fieldSettings) { + throw new Error(`Fields not found`); + } + + const field = fields.get(newId); + + if (!field) { + throw new Error(`Field not found`); + } + + const setting = fieldSettings.get(fieldId); + + if (setting) { + const newSetting = new Y.Map() as YDatabaseFieldSetting; + + setting.forEach((value, key) => { + let newValue = value; + + // Because rust uses bigint for enum or some other values, so we need to convert it to string + // Yjs cannot set bigint value directly + if (typeof value === 'bigint') { + newValue = Number(value); + } + + newSetting.set(key, newValue); + }); + fieldSettings.set(newId, newSetting); + } + + const index = fieldOrders.toArray().findIndex((field) => field.id === fieldId); + + fieldOrders.insert(index + 1, [ + { + id: newId, + }, + ]); + }, + 'insertDuplicateProperty' + ); + + if (!rowDocs) { + throw new Error(`Row docs not found`); + } + + const rows = Object.keys(rowDocs); + + if (!rows) { + throw new Error(`Row orders not found`); + } + + // Clone cell for each row + rows.forEach((rowId) => { + const rowDoc = rowDocs?.[rowId]; + + if (!rowDoc) { + return; + } + + rowDoc.transact(() => { + const rowSharedRoot = rowDoc.getMap(YjsEditorKey.data_section) as YSharedRoot; + const rowData = rowSharedRoot.get(YjsEditorKey.database_row); + + const cells = rowData.get(YjsDatabaseKey.cells); + + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); + const fieldType = Number(field.get(YjsDatabaseKey.type)); + + const cell = cells.get(fieldId); + const newCell = cloneCell(fieldType, cell); + + cells.set(newId, newCell); + + if (fieldType !== FieldType.CreatedTime && fieldType !== FieldType.LastEditedTime) { + rowData.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + } + }); + }); + + return newId; + }, + [database, rowDocs, sharedRoot] + ); +} + +export function useUpdateRowMetaDispatch(rowId: string) { + const rowDocMap = useRowDocMap(); + + const rowDoc = rowDocMap?.[rowId]; + + return useCallback( + (key: RowMetaKey, value?: string | boolean) => { + if (!rowDoc) { + throw new Error(`Row not found`); + } + + const rowSharedRoot = rowDoc.getMap(YjsEditorKey.data_section) as YSharedRoot; + const row = rowSharedRoot.get(YjsEditorKey.database_row); + const meta = rowSharedRoot.get(YjsEditorKey.meta); + + const keyId = getMetaIdMap(rowId).get(key); + + if (!keyId) { + throw new Error(`Meta key not found: ${key}`); + } + + const isDifferent = meta.get(keyId) !== value; + + if (!isDifferent) { + return; + } + + rowDoc.transact(() => { + if (value === undefined) { + meta.delete(keyId); + } else { + meta.set(keyId, value); + } + + row.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + }); + }, + [rowDoc, rowId] + ); +} + +function updateDateCell( + cell: YDatabaseCell, + payload: { + data: string; + endTimestamp?: string; + includeTime?: boolean; + isRange?: boolean; + reminderId?: string; + } +) { + cell.set(YjsDatabaseKey.data, payload.data); + + if (payload.endTimestamp !== undefined) { + cell.set(YjsDatabaseKey.end_timestamp, payload.endTimestamp); + } + + if (payload.includeTime !== undefined) { + console.log('includeTime', payload.includeTime); + cell.set(YjsDatabaseKey.include_time, payload.includeTime); + } + + if (payload.isRange !== undefined) { + cell.set(YjsDatabaseKey.is_range, payload.isRange); + } + + if (payload.reminderId !== undefined) { + cell.set(YjsDatabaseKey.reminder_id, payload.reminderId); + } +} + +export function useUpdateCellDispatch(rowId: string, fieldId: string) { + const rowDocMap = useRowDocMap(); + const { field } = useFieldSelector(fieldId); + + return useCallback( + ( + data: string | Y.Array, + dateOpts?: { + endTimestamp?: string; + includeTime?: boolean; + isRange?: boolean; + reminderId?: string; + } + ) => { + const rowDoc = rowDocMap?.[rowId]; + + if (!rowDoc) { + throw new Error(`Row not found`); + } + + const rowSharedRoot = rowDoc.getMap(YjsEditorKey.data_section) as YSharedRoot; + const row = rowSharedRoot.get(YjsEditorKey.database_row); + const cells = row.get(YjsDatabaseKey.cells); + const cell = cells.get(fieldId); + + const type = Number(field.get(YjsDatabaseKey.type)); + + rowDoc.transact(() => { + if (!cell) { + const newCell = new Y.Map() as YDatabaseCell; + + newCell.set(YjsDatabaseKey.created_at, String(dayjs().unix())); + newCell.set(YjsDatabaseKey.field_type, type); + newCell.set(YjsDatabaseKey.data, data); + newCell.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + + if (dateOpts && (typeof data === 'string' || typeof data === 'number')) { + updateDateCell(newCell, { + data, + ...dateOpts, + }); + } + + cells.set(fieldId, newCell); + } else { + cell.set(YjsDatabaseKey.data, data); + + if (dateOpts && (typeof data === 'string' || typeof data === 'number')) { + updateDateCell(cell, { + data, + ...dateOpts, + }); + } + + cell.set(YjsDatabaseKey.field_type, type); + cell.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + } + + row.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + }); + }, + [field, fieldId, rowDocMap, rowId] + ); +} + +function generateBoardSetting(database: YDatabase): YDatabaseFieldSettings { + const fieldSettingsMap = new Y.Map() as YDatabaseFieldSettings; + + const boardFields = database.get(YjsDatabaseKey.fields); + + if (!boardFields) { + return fieldSettingsMap; + } + + boardFields.forEach((_, id) => { + const setting = new Y.Map() as YDatabaseFieldSetting; + + setting.set(YjsDatabaseKey.visibility, FieldVisibility.HideWhenEmpty); + + fieldSettingsMap.set(id, setting); + }); + + return fieldSettingsMap; +} + +function generateBoardLayoutSettings() { + const layoutSettings = new Y.Map() as YDatabaseLayoutSettings; + const layoutSetting = new Y.Map() as YDatabaseBoardLayoutSetting; + + layoutSetting.set(YjsDatabaseKey.hide_ungrouped_column, true); + layoutSetting.set(YjsDatabaseKey.collapse_hidden_groups, true); + layoutSettings.set('1', layoutSetting); + return layoutSettings; +} + +function generateBoardGroup(database: YDatabase, fieldOrders: YDatabaseFieldOrders) { + const groups = new Y.Array() as YDatabaseGroups; + let groupField: YDatabaseField | undefined; + + fieldOrders.toArray().some(({ id }) => { + const field = database.get(YjsDatabaseKey.fields)?.get(id); + + if (!field) { + return; + } + + const type = Number(field.get(YjsDatabaseKey.type)); + + if ( + [ + FieldType.SingleSelect, + FieldType.MultiSelect, + FieldType.Checkbox, + // FieldType.DateTime, + // FieldType.CreatedTime, + // FieldType.LastEditedTime, + ].includes(type) + ) { + groupField = field; + return true; + } + + return false; + }); + + if (groupField) { + const group = generateGroupByField(groupField); + + groups.push([group]); + } + + return groups; +} + +export function useAddDatabaseView() { + const { iidIndex, createFolderView } = useDatabaseContext(); + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + + return useCallback( + async (layout: DatabaseViewLayout) => { + if (!createFolderView) { + throw new Error('createFolderView not found'); + } + + const viewLayout = { + [DatabaseViewLayout.Grid]: ViewLayout.Grid, + [DatabaseViewLayout.Board]: ViewLayout.Board, + [DatabaseViewLayout.Calendar]: ViewLayout.Calendar, + }[layout]; + const name = { + [DatabaseViewLayout.Grid]: 'Grid', + [DatabaseViewLayout.Board]: 'Board', + [DatabaseViewLayout.Calendar]: 'Calendar', + }[layout]; + const databaseId = database.get(YjsDatabaseKey.id); + + const newViewId = await createFolderView({ + layout: viewLayout, + parentViewId: iidIndex, + name, + databaseId, + }); + + const views = database.get(YjsDatabaseKey.views); + const refView = database.get(YjsDatabaseKey.views)?.get(iidIndex); + const refRowOrders = refView.get(YjsDatabaseKey.row_orders); + const refFieldOrders = refView.get(YjsDatabaseKey.field_orders); + + executeOperations( + sharedRoot, + [ + () => { + const newView = new Y.Map() as YDatabaseView; + const rowOrders = new Y.Array() as YDatabaseRowOrders; + const fieldOrders = new Y.Array() as YDatabaseFieldOrders; + let fieldSettings = new Y.Map() as YDatabaseFieldSettings; + let layoutSettings = new Y.Map() as YDatabaseLayoutSettings; + const filters = new Y.Array() as YDatabaseFilters; + const sorts = new Y.Array() as YDatabaseSorts; + let groups = new Y.Array() as YDatabaseGroups; + const calculations = new Y.Array() as YDatabaseCalculations; + + refRowOrders.forEach((rowOrder) => { + const newRowOrder = { + ...rowOrder, + }; + + rowOrders.push([newRowOrder]); + }); + + refFieldOrders.forEach((fieldOrder) => { + const newFieldOrder = { + ...fieldOrder, + }; + + fieldOrders.push([newFieldOrder]); + }); + + if (layout === DatabaseViewLayout.Board) { + groups = generateBoardGroup(database, refFieldOrders); + fieldSettings = generateBoardSetting(database); + layoutSettings = generateBoardLayoutSettings(); + } + + newView.set(YjsDatabaseKey.database_id, databaseId); + newView.set(YjsDatabaseKey.name, name); + newView.set(YjsDatabaseKey.layout, layout); + newView.set(YjsDatabaseKey.row_orders, rowOrders); + newView.set(YjsDatabaseKey.field_orders, fieldOrders); + newView.set(YjsDatabaseKey.created_at, String(dayjs().unix())); + newView.set(YjsDatabaseKey.modified_at, String(dayjs().unix())); + newView.set(YjsDatabaseKey.field_settings, fieldSettings); + newView.set(YjsDatabaseKey.layout_settings, layoutSettings); + newView.set(YjsDatabaseKey.filters, filters); + newView.set(YjsDatabaseKey.sorts, sorts); + newView.set(YjsDatabaseKey.groups, groups); + newView.set(YjsDatabaseKey.calculations, calculations); + newView.set(YjsDatabaseKey.is_inline, false); + + views.set(newViewId, newView); + }, + ], + 'addDatabaseView' + ); + return newViewId; + }, + [createFolderView, database, iidIndex, sharedRoot] + ); +} + +export function useUpdateDatabaseLayout(viewId: string) { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (layout: DatabaseViewLayout) => { + executeOperations( + sharedRoot, + [ + () => { + const view = database.get(YjsDatabaseKey.views)?.get(viewId); + + if (!view) { + throw new Error(`View not found`); + } + + if (Number(view.get(YjsDatabaseKey.layout)) === layout) { + return; + } + + if (layout === DatabaseViewLayout.Board) { + const fieldOrders = view.get(YjsDatabaseKey.field_orders); + const groups = generateBoardGroup(database, fieldOrders); + const settings = generateBoardSetting(database); + const layoutSettings = generateBoardLayoutSettings(); + + view.set(YjsDatabaseKey.groups, groups); + view.set(YjsDatabaseKey.field_settings, settings); + view.set(YjsDatabaseKey.layout_settings, layoutSettings); + } + + view.set(YjsDatabaseKey.layout, layout); + }, + ], + 'updateDatabaseLayout' + ); + }, + [database, sharedRoot, viewId] + ); +} + +export function useUpdateDatabaseView() { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + const { updatePage } = useDatabaseContext(); + + return useCallback( + async (viewId: string, payload: UpdatePagePayload) => { + await updatePage?.(viewId, payload); + + executeOperations( + sharedRoot, + [ + () => { + const view = database.get(YjsDatabaseKey.views)?.get(viewId); + + if (!view) { + throw new Error(`View not found`); + } + + const name = payload.name || view.get(YjsDatabaseKey.name); + + view.set(YjsDatabaseKey.name, name); + }, + ], + 'renameDatabaseView' + ); + }, + [database, updatePage, sharedRoot] + ); +} + +export function useDeleteView() { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + const { deletePage } = useDatabaseContext(); + + return useCallback( + async (viewId: string) => { + await deletePage?.(viewId); + + executeOperations( + sharedRoot, + [ + () => { + const view = database.get(YjsDatabaseKey.views)?.get(viewId); + + if (!view) { + throw new Error(`View not found`); + } + + database.get(YjsDatabaseKey.views)?.delete(viewId); + }, + ], + 'deleteView' + ); + }, + [database, deletePage, sharedRoot] + ); +} + +export function useSwitchPropertyType() { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + const rowDocMap = useRowDocMap(); + + return useCallback( + (fieldId: string, fieldType: FieldType) => { + if (!rowDocMap) { + throw new Error(`Row docs not found`); + } + + const rows = Object.keys(rowDocMap); + + executeOperations( + sharedRoot, + [ + () => { + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); + + if (!field) { + throw new Error(`Field not found`); + } + + const oldFieldType = Number(field.get(YjsDatabaseKey.type)); + + let typeOptionMap = field?.get(YjsDatabaseKey.type_option); + + // Check if the field type is supported for type options + if ( + [ + FieldType.Number, + FieldType.SingleSelect, + FieldType.MultiSelect, + FieldType.DateTime, + FieldType.CreatedTime, + FieldType.LastEditedTime, + FieldType.FileMedia, + ].includes(fieldType) + ) { + // Ensure the type option map is created + if (!typeOptionMap) { + typeOptionMap = new Y.Map() as YDatabaseFieldTypeOption; + + field.set(YjsDatabaseKey.type_option, typeOptionMap); + } + + const typeOption = typeOptionMap.get(String(fieldType)); + + // Check if the type option is created, if not, create it with default values + // Otherwise, just ignore it + if (typeOption === undefined || Array.from(typeOption.keys()).length === 0) { + const newTypeOption = new Y.Map() as YMapFieldTypeOption; + + // Set default values for the type option + if ([FieldType.CreatedTime, FieldType.LastEditedTime, FieldType.DateTime].includes(fieldType)) { + // to DateTime + newTypeOption.set(YjsDatabaseKey.time_format, TimeFormat.TwentyFourHour); + newTypeOption.set(YjsDatabaseKey.date_format, DateFormat.Friendly); + if (oldFieldType !== FieldType.DateTime) { + newTypeOption.set(YjsDatabaseKey.include_time, true); + } + } else if (fieldType === FieldType.Number) { + // to Number + newTypeOption.set(YjsDatabaseKey.format, NumberFormat.Num); + } else if ([FieldType.SingleSelect, FieldType.MultiSelect].includes(fieldType)) { + // to Select + const rows = Object.keys(rowDocMap); + let content = ''; + + switch (oldFieldType) { + // From SingleSelect or MultiSelect to Select, keep the content + case FieldType.SingleSelect: + case FieldType.MultiSelect: { + const oldTypeOption = typeOptionMap.get(String(oldFieldType)); + + if (oldTypeOption) { + content = oldTypeOption.get(YjsDatabaseKey.content) || ''; + } + + break; + } + + // From other types to Select, generate options from rows + case FieldType.Checkbox: + case FieldType.RichText: + case FieldType.Number: + case FieldType.URL: { + const options = new Set(); + + // If the old field type is Checkbox, add Yes/No options + if (oldFieldType === FieldType.Checkbox) { + options.add('Yes'); + options.add('No'); + } else { + rows.forEach((rowId) => { + const rowDoc = rowDocMap[rowId]; + + if (!rowDoc) { + return; + } + + getOptionsFromRow(rowDoc, fieldId).forEach((option) => { + options.add(option); + }); + }); + } + + content = JSON.stringify({ + disable_color: false, + options: Array.from(options).map((name) => { + return { + id: name, + name, + color: getColorByOption(name), + }; + }), + }); + + break; + } + } + + // Set the content for the type option + newTypeOption.set(YjsDatabaseKey.content, content); + } else if (fieldType === FieldType.FileMedia) { + // to FileMedia + const content = JSON.stringify({ + hide_file_names: true, + }); + + newTypeOption.set(YjsDatabaseKey.content, content); + } + + typeOptionMap.set(String(fieldType), newTypeOption); + } + } + + field.set(YjsDatabaseKey.type, fieldType); + + const lastModified = field.get(YjsDatabaseKey.last_modified); + + // Before update-last modified time, check if the field is created + if (!lastModified) { + const fieldName = getFieldName(fieldType); + + // Set the default name for the field if it is created + field.set(YjsDatabaseKey.name, fieldName); + } + + field.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + + rows.forEach((row) => { + const rowDoc = rowDocMap?.[row]; + + if (!rowDoc) { + return; + } + + rowDoc.transact(() => { + const rowSharedRoot = rowDoc.getMap(YjsEditorKey.data_section) as YSharedRoot; + const row = rowSharedRoot.get(YjsEditorKey.database_row); + const cells = row.get(YjsDatabaseKey.cells); + const cell = cells.get(fieldId); + + // Update each cell + if (cell) { + const data = cell.get(YjsDatabaseKey.data); + let newData = data instanceof Y.Array ? data.clone() : data; + + // From Relation or Files & Media to other types, clear the data + if ([FieldType.Relation, FieldType.Checklist].includes(oldFieldType)) { + newData = null; + } else { + // Handle transformation of data based on the new field type + // 1. to RichText + if ([FieldType.RichText, FieldType.URL].includes(fieldType)) { + const cellType = Number(cell.get(YjsDatabaseKey.field_type)); + const typeOption = field.get(YjsDatabaseKey.type_option)?.get(String(cellType)); + + switch (cellType) { + // From Number to RichText, keep the number format value + case FieldType.Number: { + const format = + (Number(typeOption.get(YjsDatabaseKey.format)) as NumberFormat) ?? NumberFormat.Num; + + if (data) { + newData = EnhancedBigStats.parse(data.toString(), format) || ''; + } + + break; + } + + case FieldType.SingleSelect: + case FieldType.MultiSelect: { + const selectedIds = (data as string).split(','); + const typeOption = typeOptionMap.get(String(cellType)); + const content = typeOption.get(YjsDatabaseKey.content); + + try { + const parsedContent = JSON.parse(content) as SelectTypeOption; + const options = parsedContent.options; + const selectedNames = selectedIds + .map((id) => { + const option = options.find((opt) => opt.id === id); + + if (!option) { + return ''; + } + + return option.name; + }) + .filter((name) => name !== ''); + + newData = selectedNames.join(','); + } catch (e) { + // do nothing + } + + break; + } + + case FieldType.DateTime: { + const dateCell = parseYDatabaseDateTimeCellToCell(cell); + + newData = getDateCellStr({ + cell: dateCell, + field, + }); + + break; + } + + default: + break; + } + } + + // 2. to Number + if (fieldType === FieldType.Number) { + if (oldFieldType === FieldType.Checkbox) { + // From Checkbox to Number, convert Yes/No to 1/0 + newData = (data as string).toLowerCase() === 'yes' ? '1' : '0'; + } else if ((typeof data === 'number' || typeof data === 'string') && !isNaN(Number(data))) { + // From other types to Number, keep the number format value + newData = data; + } else { + const start = + typeof data === 'number' || typeof data === 'string' + ? data.toString().split(RIGHTWARDS_ARROW)[0] + : ''; + + // If the data is a date string, convert it to a timestamp + if (data && start && isDate(start)) { + const date = safeParseTimestamp(start); + + if (date) { + newData = date.unix().toString(); + } + } + } + } + + // 3. to SingleSelect or MultiSelect + if ([FieldType.SingleSelect, FieldType.MultiSelect].includes(fieldType)) { + const typeOption = typeOptionMap.get(String(fieldType)); + const content = typeOption.get(YjsDatabaseKey.content); + + try { + const parsedContent = JSON.parse(content) as SelectTypeOption; + const options = parsedContent.options; + + const selectedOptionNames = (data as string).split(','); + const selectedOptionIds = selectedOptionNames + .map((name) => { + const option = options.find((opt) => opt.name === name || opt.id === name); + + if (!option) { + return ''; + } + + return option.id; + }) + .filter((id) => id !== ''); + + newData = selectedOptionIds.join(','); + } catch (e) { + // do nothing + } + } + + // 4. to DateTime + if (fieldType === FieldType.DateTime) { + if (data && (typeof data === 'string' || typeof data === 'number')) { + const start = data.toString().split('-')[0]; + + newData = safeParseTimestamp(start).unix(); + } + } + + // 5. to Relation or Files & Media + if ([FieldType.Relation].includes(fieldType)) { + newData = new Y.Array(); + } + } + + cell.set(YjsDatabaseKey.field_type, fieldType); + cell.set(YjsDatabaseKey.data, newData); + cell.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + row.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + } + }); + }); + }, + ], + 'switchPropertyType' + ); + }, + [database, sharedRoot, rowDocMap] + ); +} + +export function useUpdateNumberTypeOption() { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (fieldId: string, format: NumberFormat) => { + executeOperations( + sharedRoot, + [ + () => { + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); + + if (!field) { + throw new Error(`Field not found`); + } + + let typeOptionMap = field?.get(YjsDatabaseKey.type_option); + + if (!typeOptionMap) { + typeOptionMap = new Y.Map() as YDatabaseFieldTypeOption; + + field.set(YjsDatabaseKey.type_option, typeOptionMap); + } + + const typeOption = typeOptionMap.get(String(FieldType.Number)); + + if (!typeOption) { + const newTypeOption = new Y.Map() as YMapFieldTypeOption; + + newTypeOption.set(YjsDatabaseKey.format, format); + + typeOptionMap.set(String(FieldType.Number), newTypeOption); + } else { + typeOption.set(YjsDatabaseKey.format, format); + } + + field.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + }, + ], + 'updateNumberTypeOption' + ); + }, + [database, sharedRoot] + ); +} + +export function useUpdateTranslateLanguage(fieldId: string) { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (language: AITranslateLanguage) => { + executeOperations( + sharedRoot, + [ + () => { + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); + + if (!field) { + throw new Error(`Field not found`); + } + + let typeOptionMap = field?.get(YjsDatabaseKey.type_option); + + if (!typeOptionMap) { + typeOptionMap = new Y.Map() as YDatabaseFieldTypeOption; + + field.set(YjsDatabaseKey.type_option, typeOptionMap); + } + + const typeOption = typeOptionMap.get(String(FieldType.AITranslations)); + + if (!typeOption) { + const newTypeOption = new Y.Map() as YMapFieldTypeOption; + + newTypeOption.set(YjsDatabaseKey.language, language); + + typeOptionMap.set(String(FieldType.AITranslations), newTypeOption); + } else { + typeOption.set(YjsDatabaseKey.language, language); + } + + field.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + }, + ], + 'updateTranslateLanguage' + ); + }, + [database, fieldId, sharedRoot] + ); +} + +export function useAddSelectOption(fieldId: string) { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (option: SelectOption) => { + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); + + if (!field) { + throw new Error(`Field not found`); + } + + executeOperations( + sharedRoot, + [ + () => { + const fieldType = Number(field.get(YjsDatabaseKey.type)); + + let typeOptionMap = field?.get(YjsDatabaseKey.type_option); + + if (!typeOptionMap) { + typeOptionMap = new Y.Map() as YDatabaseFieldTypeOption; + + field.set(YjsDatabaseKey.type_option, typeOptionMap); + } + + let typeOption = typeOptionMap.get(String(fieldType)); + + if (!typeOption) { + typeOption = new Y.Map() as YMapFieldTypeOption; + + typeOption.set( + YjsDatabaseKey.content, + JSON.stringify({ + disable_color: false, + options: [], + }) + ); + + typeOptionMap.set(String(fieldType), typeOption); + } + + const content = typeOption.get(YjsDatabaseKey.content); + + if (!content) { + throw new Error(`Content not found`); + } + + const options = JSON.parse(content) as SelectTypeOption; + const newOptions = [...options.options]; + + // Check if the option already exists + if (newOptions.some((opt) => opt.name === option.name)) { + return; + } + + newOptions.push(option); + typeOption.set( + YjsDatabaseKey.content, + JSON.stringify({ + ...options, + options: newOptions, + }) + ); + }, + ], + 'addSelectOption' + ); + + executeOperationWithAllViews( + sharedRoot, + database, + (view) => { + const groups = view?.get(YjsDatabaseKey.groups); + + const group = groups?.toArray().find((item) => { + return item.get(YjsDatabaseKey.field_id) === fieldId; + }); + + if (group) { + const columns = group.get(YjsDatabaseKey.groups); + const column = columns.toArray().find((col) => col.id === option.id); + + if (!column) { + columns.push([ + { + id: option.id, + visible: true, + }, + ]); + } + } + }, + 'insertSelectOptionToGroup' + ); + }, + [database, fieldId, sharedRoot] + ); +} + +export function useReorderSelectFieldOptions(fieldId: string) { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); + + if (!field) { + throw new Error(`Field not found`); + } + + return useCallback( + (optionId: string, beforeId?: string) => { + executeOperations( + sharedRoot, + [ + () => { + const fieldType = Number(field.get(YjsDatabaseKey.type)); + + let typeOptionMap = field?.get(YjsDatabaseKey.type_option); + + if (!typeOptionMap) { + typeOptionMap = new Y.Map() as YDatabaseFieldTypeOption; + + field.set(YjsDatabaseKey.type_option, typeOptionMap); + } + + let typeOption = typeOptionMap.get(String(fieldType)); + + if (!typeOption) { + typeOption = new Y.Map() as YMapFieldTypeOption; + + typeOption.set( + YjsDatabaseKey.content, + JSON.stringify({ + disable_color: false, + options: [], + }) + ); + + typeOptionMap.set(String(fieldType), typeOption); + } + + let content = typeOption.get(YjsDatabaseKey.content); + + if (!content) { + content = JSON.stringify({ + disable_color: false, + options: [], + }); + } + + const data = JSON.parse(content) as SelectTypeOption; + + const options = data.options; + + const index = options.findIndex((opt) => opt.id === optionId); + const option = options[index]; + + if (index === -1) { + return; + } + + const newOptions = [...options]; + const beforeIndex = newOptions.findIndex((opt) => opt.id === beforeId); + + if (beforeIndex === index) { + return; + } + + newOptions.splice(index, 1); + + if (beforeId === undefined || beforeIndex === -1) { + newOptions.unshift(option); + } else { + const targetIndex = beforeIndex > index ? beforeIndex - 1 : beforeIndex; + + newOptions.splice(targetIndex + 1, 0, option); + } + + typeOption.set( + YjsDatabaseKey.content, + JSON.stringify({ + ...data, + options: newOptions, + }) + ); + }, + ], + 'updateSelectOptions' + ); + }, + [field, sharedRoot] + ); +} + +export function useDeleteSelectOption(fieldId: string) { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (optionId: string) => { + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); + + if (!field) { + throw new Error(`Field not found`); + } + + executeOperations( + sharedRoot, + [ + () => { + const fieldType = Number(field.get(YjsDatabaseKey.type)); + + if (![FieldType.SingleSelect, FieldType.MultiSelect].includes(fieldType)) { + return; + } + + let typeOptionMap = field?.get(YjsDatabaseKey.type_option); + + if (!typeOptionMap) { + typeOptionMap = new Y.Map() as YDatabaseFieldTypeOption; + + field.set(YjsDatabaseKey.type_option, typeOptionMap); + } + + let typeOption = typeOptionMap.get(String(fieldType)); + + if (!typeOption) { + typeOption = new Y.Map() as YMapFieldTypeOption; + + typeOption.set( + YjsDatabaseKey.content, + JSON.stringify({ + disable_color: false, + options: [], + }) + ); + + typeOptionMap.set(String(fieldType), typeOption); + } + + const content = typeOption.get(YjsDatabaseKey.content); + + if (!content) { + throw new Error(`Content not found`); + } + + const options = JSON.parse(content) as SelectTypeOption; + const newOptions = options.options.filter((opt) => opt.id !== optionId); + + typeOption.set( + YjsDatabaseKey.content, + JSON.stringify({ + ...options, + options: newOptions, + }) + ); + }, + ], + 'deleteSelectOption' + ); + + executeOperationWithAllViews( + sharedRoot, + database, + (view) => { + const groups = view?.get(YjsDatabaseKey.groups); + + const group = groups?.toArray().find((item) => { + return item.get(YjsDatabaseKey.field_id) === fieldId; + }); + + if (group) { + const columns = group.get(YjsDatabaseKey.groups); + const columnIndex = columns.toArray().findIndex((col) => col.id === optionId); + + if (columnIndex !== -1) { + columns.delete(columnIndex); + } + } + }, + 'deleteSelectOptionFromGroup' + ); + }, + [database, fieldId, sharedRoot] + ); +} + +export function useUpdateSelectOption(fieldId: string) { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (optionId: string, option: SelectOption) => { + executeOperations( + sharedRoot, + [ + () => { + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); + + if (!field) { + throw new Error(`Field not found`); + } + + const fieldType = Number(field.get(YjsDatabaseKey.type)); + + let typeOptionMap = field?.get(YjsDatabaseKey.type_option); + + if (!typeOptionMap) { + typeOptionMap = new Y.Map() as YDatabaseFieldTypeOption; + + field.set(YjsDatabaseKey.type_option, typeOptionMap); + } + + let typeOption = typeOptionMap.get(String(fieldType)); + + if (!typeOption) { + typeOption = new Y.Map() as YMapFieldTypeOption; + + typeOption.set( + YjsDatabaseKey.content, + JSON.stringify({ + disable_color: false, + options: [], + }) + ); + + typeOptionMap.set(String(fieldType), typeOption); + } + + const content = typeOption.get(YjsDatabaseKey.content); + + if (!content) { + throw new Error(`Content not found`); + } + + const options = JSON.parse(content) as SelectTypeOption; + + const newOptions = options.options.map((opt) => { + if (opt.id === optionId) { + return option; + } + + return opt; + }); + + typeOption.set( + YjsDatabaseKey.content, + JSON.stringify({ + ...options, + options: newOptions, + }) + ); + }, + ], + 'updateSelectOption' + ); + }, + [database, fieldId, sharedRoot] + ); +} + +export function useUpdateDateTimeFieldFormat(fieldId: string) { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + + return useCallback( + ({ + dateFormat, + timeFormat, + includeTime, + }: { + dateFormat?: DateFormat; + timeFormat?: TimeFormat; + includeTime?: boolean; + }) => { + executeOperations( + sharedRoot, + [ + () => { + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); + + if (!field) { + throw new Error(`Field not found`); + } + + let typeOptionMap = field?.get(YjsDatabaseKey.type_option); + + if (!typeOptionMap) { + typeOptionMap = new Y.Map() as YDatabaseFieldTypeOption; + + field.set(YjsDatabaseKey.type_option, typeOptionMap); + } + + const fieldType = Number(field.get(YjsDatabaseKey.type)); + + let typeOption = typeOptionMap.get(String(fieldType)); + + if (!typeOption) { + typeOption = new Y.Map() as YMapFieldTypeOption; + typeOptionMap.set(String(FieldType.DateTime), typeOption); + } + + if (dateFormat !== undefined) { + typeOption.set(YjsDatabaseKey.date_format, dateFormat); + } + + if (timeFormat !== undefined) { + typeOption.set(YjsDatabaseKey.time_format, timeFormat); + } + + if (includeTime !== undefined) { + typeOption.set(YjsDatabaseKey.include_time, includeTime); + } + }, + ], + 'updateDateTimeFieldFormat' + ); + }, + [database, fieldId, sharedRoot] + ); +} + +export function useUpdateRelationDatabaseId(fieldId: string) { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + const clearCells = useClearCellsWithFieldDispatch(); + + return useCallback( + (databaseId: string) => { + // Check if the relation database id is dirty + let isDirty = false; + + executeOperations( + sharedRoot, + [ + () => { + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); + + if (!field) { + throw new Error(`Field not found`); + } + + let typeOptionMap = field?.get(YjsDatabaseKey.type_option); + + if (!typeOptionMap) { + typeOptionMap = new Y.Map() as YDatabaseFieldTypeOption; + + field.set(YjsDatabaseKey.type_option, typeOptionMap); + } + + const fieldType = Number(field.get(YjsDatabaseKey.type)); + + let typeOption = typeOptionMap.get(String(fieldType)); + + if (!typeOption) { + typeOption = new Y.Map() as YMapFieldTypeOption; + typeOptionMap.set(String(fieldType), typeOption); + } + + // Check if the relation database id is dirty + if (typeOption.get(YjsDatabaseKey.database_id) !== databaseId) { + isDirty = true; + } + + typeOption.set(YjsDatabaseKey.database_id, databaseId); + + field.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + }, + ], + 'updateRelationDatabaseId' + ); + + // Clear cells when the relation database id is changed + if (isDirty) { + clearCells(fieldId); + } + }, + [database, fieldId, sharedRoot, clearCells] + ); +} + +export function useAddSort() { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (fieldId: string) => { + if (!view) return; + executeOperations( + sharedRoot, + [ + () => { + let sorts = view.get(YjsDatabaseKey.sorts); + + if (!sorts) { + sorts = new Y.Array() as YDatabaseSorts; + view.set(YjsDatabaseKey.sorts, sorts); + } + + const isExist = sorts.toArray().some((sort) => { + const sortFieldId = sort.get(YjsDatabaseKey.field_id); + + if (sortFieldId === fieldId) { + return true; + } + + return false; + }); + + if (isExist) { + return; + } + + const sort = new Y.Map() as YDatabaseSort; + const id = `${nanoid(6)}`; + + sort.set(YjsDatabaseKey.id, id); + sort.set(YjsDatabaseKey.field_id, fieldId); + sort.set(YjsDatabaseKey.condition, SortCondition.Ascending); + + sorts.push([sort]); + }, + ], + 'addSort' + ); + }, + [view, sharedRoot] + ); +} + +export function useRemoveSort() { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (sortId: string) => { + if (!view) return; + executeOperations( + sharedRoot, + [ + () => { + const sorts = view.get(YjsDatabaseKey.sorts); + + if (!sorts) { + return; + } + + const index = sorts.toArray().findIndex((sort) => sort.get(YjsDatabaseKey.id) === sortId); + + if (index === -1) { + return; + } + + sorts.delete(index); + }, + ], + 'removeSort' + ); + }, + [view, sharedRoot] + ); +} + +export function useUpdateSort() { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + + return useCallback( + ({ sortId, fieldId, condition }: { sortId: string; fieldId?: string; condition?: SortCondition }) => { + if (!view) return; + executeOperations( + sharedRoot, + [ + () => { + const sorts = view.get(YjsDatabaseKey.sorts); + + if (!sorts) { + return; + } + + const sort = sorts.toArray().find((sort) => sort.get(YjsDatabaseKey.id) === sortId); + + if (!sort) { + return; + } + + if (fieldId) { + sort.set(YjsDatabaseKey.field_id, fieldId); + } + + if (condition !== undefined) { + sort.set(YjsDatabaseKey.condition, condition); + } + }, + ], + 'updateSort' + ); + }, + [view, sharedRoot] + ); +} + +export function useReorderSorts() { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (sortId: string, beforeId?: string) => { + if (!view) return; + executeOperations( + sharedRoot, + [ + () => { + const sorts = view.get(YjsDatabaseKey.sorts); + + if (!sorts) { + return; + } + + const sortArray = sorts.toJSON() as { + id: string; + }[]; + + const sourceIndex = sortArray.findIndex((sort) => sort.id === sortId); + const targetIndex = beforeId !== undefined ? sortArray.findIndex((sort) => sort.id === beforeId) + 1 : 0; + + const sort = sorts.get(sourceIndex); + + const newSort = new Y.Map() as YDatabaseSort; + + sort.forEach((value, key) => { + let newValue = value; + + // Because rust uses bigint for enum or some other values, so we need to convert it to string + // Yjs cannot set bigint value directly + if (typeof value === 'bigint') { + newValue = value.toString(); + } + + newSort.set(key, newValue); + }); + + sorts.delete(sourceIndex); + + let adjustedTargetIndex = targetIndex; + + if (targetIndex > sourceIndex) { + adjustedTargetIndex -= 1; + } + + sorts.insert(adjustedTargetIndex, [newSort]); + }, + ], + 'reorderSort' + ); + }, + [view, sharedRoot] + ); +} + +export function useAddFilter() { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + const fields = useDatabaseFields(); + + return useCallback( + (fieldId: string) => { + if (!view) return; + const id = `${nanoid(6)}`; + + executeOperations( + sharedRoot, + [ + () => { + const field = fields.get(fieldId); + const fieldType = Number(field.get(YjsDatabaseKey.type)); + let filters = view.get(YjsDatabaseKey.filters); + + if (!filters) { + filters = new Y.Array() as YDatabaseFilters; + view.set(YjsDatabaseKey.filters, filters); + } + + const filter = new Y.Map() as YDatabaseFilter; + + filter.set(YjsDatabaseKey.id, id); + filter.set(YjsDatabaseKey.field_id, fieldId); + const conditionData = getDefaultFilterCondition(fieldType); + + if (!conditionData) return; + + filter.set(YjsDatabaseKey.condition, conditionData.condition); + if (conditionData.content !== undefined) { + filter.set(YjsDatabaseKey.content, conditionData.content); + } + + filter.set(YjsDatabaseKey.type, fieldType); + filter.set(YjsDatabaseKey.filter_type, FilterType.Data); + + filters.push([filter]); + }, + ], + 'addFilter' + ); + + return id; + }, + [view, sharedRoot, fields] + ); +} + +export function useRemoveFilter() { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (filterId: string) => { + if (!view) return; + executeOperations( + sharedRoot, + [ + () => { + const filters = view.get(YjsDatabaseKey.filters); + + if (!filters) { + return; + } + + const index = filters.toArray().findIndex((filter) => filter.get(YjsDatabaseKey.id) === filterId); + + if (index === -1) { + return; + } + + filters.delete(index); + }, + ], + 'removeFilter' + ); + }, + [view, sharedRoot] + ); +} + +export function useUpdateFilter() { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + + return useCallback( + ({ + filterId, + fieldId, + condition, + content, + }: { + filterId: string; + fieldId?: string; + condition?: number; + content?: string; + }) => { + if (!view) return; + executeOperations( + sharedRoot, + [ + () => { + const filters = view.get(YjsDatabaseKey.filters); + + if (!filters) { + return; + } + + const filter = filters.toArray().find((filter) => filter.get(YjsDatabaseKey.id) === filterId); + + if (!filter) { + return; + } + + if (fieldId) { + filter.set(YjsDatabaseKey.field_id, fieldId); + } + + if (condition !== undefined) { + filter.set(YjsDatabaseKey.condition, condition); + } + + if (content !== undefined) { + filter.set(YjsDatabaseKey.content, content); + } + }, + ], + 'updateFilter' + ); + }, + [view, sharedRoot] + ); +} + +export function useUpdateFileMediaTypeOption(fieldId: string) { + const database = useDatabase(); + const sharedRoot = useSharedRoot(); + + return useCallback( + ({ hideFileNames }: { hideFileNames: boolean }) => { + executeOperations( + sharedRoot, + [ + () => { + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); + + if (!field) { + throw new Error(`Field not found`); + } + + let typeOptionMap = field?.get(YjsDatabaseKey.type_option); + + if (!typeOptionMap) { + typeOptionMap = new Y.Map() as YDatabaseFieldTypeOption; + + field.set(YjsDatabaseKey.type_option, typeOptionMap); + } + + const typeOption = typeOptionMap.get(String(FieldType.FileMedia)); + + if (!typeOption) { + const newTypeOption = new Y.Map() as YMapFieldTypeOption; + + newTypeOption.set( + YjsDatabaseKey.content, + JSON.stringify({ + hide_file_names: hideFileNames, + }) + ); + typeOptionMap.set(String(FieldType.FileMedia), newTypeOption); + } else { + console.log('Updating file media type option', typeOption.toJSON()); + typeOption.set( + YjsDatabaseKey.content, + JSON.stringify({ + hide_file_names: hideFileNames, + }) + ); + } + }, + ], + 'updateFileMediaType' + ); + }, + [database, fieldId, sharedRoot] + ); +} diff --git a/src/application/database-yjs/fields/ai-translate/index.ts b/src/application/database-yjs/fields/ai-translate/index.ts new file mode 100644 index 00000000..001f41de --- /dev/null +++ b/src/application/database-yjs/fields/ai-translate/index.ts @@ -0,0 +1 @@ +export * from './parse'; \ No newline at end of file diff --git a/src/application/database-yjs/fields/ai-translate/parse.ts b/src/application/database-yjs/fields/ai-translate/parse.ts new file mode 100644 index 00000000..20dbc3f7 --- /dev/null +++ b/src/application/database-yjs/fields/ai-translate/parse.ts @@ -0,0 +1,41 @@ +import { AITranslateLanguage, getTypeOptions } from '@/application/database-yjs'; +import { YDatabaseField, YjsDatabaseKey } from '@/application/types'; + +export function parseAITranslateTypeOption (field: YDatabaseField) { + const typeOption = getTypeOptions(field); + + const language = typeOption ? Number(typeOption.get(YjsDatabaseKey.language)) : AITranslateLanguage.English; + + return { + language, + }; +} + +export const languageTexts = [{ + label: 'English', + value: AITranslateLanguage.English, +}, { + label: 'Traditional Chinese', + value: AITranslateLanguage.Traditional_Chinese, +}, { + label: 'Spanish', + value: AITranslateLanguage.Spanish, +}, { + label: 'French', + value: AITranslateLanguage.French, +}, { + label: 'German', + value: AITranslateLanguage.German, +}, { + label: 'Hindi', + value: AITranslateLanguage.Hindi, +}, { + label: 'Portuguese', + value: AITranslateLanguage.Portuguese, +}, { + label: 'Standard Arabic', + value: AITranslateLanguage.Standard_Arabic, +}, { + label: 'Simplified Chinese', + value: AITranslateLanguage.Simplified_Chinese, +}]; \ No newline at end of file diff --git a/src/application/database-yjs/fields/checkbox/utils.ts b/src/application/database-yjs/fields/checkbox/utils.ts new file mode 100644 index 00000000..2e08c156 --- /dev/null +++ b/src/application/database-yjs/fields/checkbox/utils.ts @@ -0,0 +1,33 @@ +import dayjs from 'dayjs'; +import * as Y from 'yjs'; + +import { FieldType } from '@/application/database-yjs'; +import { YDatabaseCell, YjsDatabaseKey } from '@/application/types'; + +export function createCheckboxCell(fieldId: string, data: string) { + const cell = new Y.Map() as YDatabaseCell; + + cell.set(YjsDatabaseKey.id, fieldId); + cell.set(YjsDatabaseKey.data, data); + cell.set(YjsDatabaseKey.field_type, String(FieldType.Checkbox)); + cell.set(YjsDatabaseKey.created_at, String(dayjs().unix())); + cell.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + + return cell; +} + +export function getChecked(data?: string | number | boolean) { + if (typeof data === 'boolean') { + return data; + } + + if (typeof data === 'number') { + return data === 1; + } + + if (typeof data === 'string') { + return ['yes', '1', 'true'].includes(data.toLowerCase()); + } + + return false; +} diff --git a/src/application/database-yjs/fields/checklist/parse.ts b/src/application/database-yjs/fields/checklist/parse.ts index c93fee7a..c5c4df3a 100644 --- a/src/application/database-yjs/fields/checklist/parse.ts +++ b/src/application/database-yjs/fields/checklist/parse.ts @@ -1,4 +1,5 @@ -import { SelectOption } from '../select-option'; +import { generateUUID } from '@/application/database-yjs'; +import { SelectOption, SelectOptionColor } from '../select-option'; export interface ChecklistCellData { selectedOptionIds?: string[]; @@ -6,7 +7,7 @@ export interface ChecklistCellData { percentage: number; } -export function parseChecklistData(data: string): ChecklistCellData | null { +export function parseChecklistData (data: string): ChecklistCellData | null { try { const { options, selected_option_ids } = JSON.parse(data); const percentage = selected_option_ids.length / options.length; @@ -20,3 +21,134 @@ export function parseChecklistData(data: string): ChecklistCellData | null { return null; } } + +export function addTask (data: string, taskName: string): string { + const parsedData = parseChecklistData(data); + + const task: SelectOption = { + id: generateUUID(), + name: taskName, + color: SelectOptionColor.Purple, + }; + + if (!parsedData) { + return JSON.stringify({ + options: [task], + selected_option_ids: [], + }); + } + + const { options = [], selectedOptionIds } = parsedData; + + if (options.find((option) => option.id === task.id)) { + return data; + } + + return JSON.stringify({ + options: [...options, task], + selected_option_ids: selectedOptionIds, + }); +} + +export function toggleSelectedTask (data: string, taskId: string): string { + const parsedData = parseChecklistData(data); + + if (!parsedData) { + return data; + } + + const { options, selectedOptionIds = [] } = parsedData; + + const isSelected = selectedOptionIds.includes(taskId); + const newSelectedOptionIds = isSelected + ? selectedOptionIds.filter((id) => id !== taskId) + : [...selectedOptionIds, taskId]; + + return JSON.stringify({ + options, + selected_option_ids: newSelectedOptionIds, + }); +} + +export function updateTask (data: string, taskId: string, taskName: string): string { + const parsedData = parseChecklistData(data); + + if (!parsedData) { + return data; + } + + const { options = [], selectedOptionIds } = parsedData; + + const newOptions = options.map((option) => { + if (option.id === taskId) { + return { + ...option, + name: taskName, + }; + } + + return option; + }); + + return JSON.stringify({ + options: newOptions, + selected_option_ids: selectedOptionIds, + }); +} + +export function removeTask (data: string, taskId: string): string { + const parsedData = parseChecklistData(data); + + if (!parsedData) { + return data; + } + + const { options = [], selectedOptionIds = [] } = parsedData; + + const newOptions = options.filter((option) => option.id !== taskId); + const newSelectedOptionIds = selectedOptionIds.filter((id) => id !== taskId); + + return JSON.stringify({ + options: newOptions, + selected_option_ids: newSelectedOptionIds, + }); +} + +export function reorderTasks (data: string, { beforeId, taskId }: { beforeId?: string, taskId: string }): string { + const parsedData = parseChecklistData(data); + + if (!parsedData) { + return data; + } + + const { selectedOptionIds, options = [] } = parsedData; + + const index = options.findIndex((opt) => opt.id === taskId); + const option = options[index]; + + if (index === -1) { + return data; + } + + const newOptions = [...options]; + const beforeIndex = newOptions.findIndex((opt) => opt.id === beforeId); + + if (beforeIndex === index) { + return data; + } + + newOptions.splice(index, 1); + + if (beforeId === undefined || beforeIndex === -1) { + newOptions.unshift(option); + } else { + const targetIndex = beforeIndex > index ? beforeIndex - 1 : beforeIndex; + + newOptions.splice(targetIndex + 1, 0, option); + } + + return JSON.stringify({ + options: newOptions, + selected_option_ids: selectedOptionIds, + }); +} \ No newline at end of file diff --git a/src/application/database-yjs/fields/date/date.type.ts b/src/application/database-yjs/fields/date/date.type.ts index 0db15f21..463fed11 100644 --- a/src/application/database-yjs/fields/date/date.type.ts +++ b/src/application/database-yjs/fields/date/date.type.ts @@ -14,14 +14,22 @@ export enum DateFormat { } export enum DateFilterCondition { - DateIs = 0, - DateBefore = 1, - DateAfter = 2, - DateOnOrBefore = 3, - DateOnOrAfter = 4, - DateWithIn = 5, - DateIsEmpty = 6, - DateIsNotEmpty = 7, + DateStartsOn = 0, + DateStartsBefore = 1, + DateStartsAfter = 2, + DateStartsOnOrBefore = 3, + DateStartsOnOrAfter = 4, + DateStartsBetween = 5, + DateStartIsEmpty = 6, + DateStartIsNotEmpty = 7, + DateEndsOn = 8, + DateEndsBefore = 9, + DateEndsAfter = 10, + DateEndsOnOrBefore = 11, + DateEndsOnOrAfter = 12, + DateEndsBetween = 13, + DateEndIsEmpty = 14, + DateEndIsNotEmpty = 15, } export interface DateFilter extends Filter { diff --git a/src/application/database-yjs/fields/date/utils.ts b/src/application/database-yjs/fields/date/utils.ts index 98540276..d6c9fd42 100644 --- a/src/application/database-yjs/fields/date/utils.ts +++ b/src/application/database-yjs/fields/date/utils.ts @@ -1,4 +1,9 @@ -import { TimeFormat, DateFormat } from '@/application/database-yjs'; +import dayjs from 'dayjs'; + +import { DateFormat, getTypeOptions, TimeFormat } from '@/application/database-yjs'; +import { DateTimeCell } from '@/application/database-yjs/cell.type'; +import { YDatabaseField, YjsDatabaseKey } from '@/application/types'; +import { renderDate } from '@/utils/time'; export function getTimeFormat(timeFormat?: TimeFormat) { switch (timeFormat) { @@ -27,3 +32,102 @@ export function getDateFormat(dateFormat?: DateFormat) { return 'YYYY-MM-DD'; } } + +function getDateTimeStr({ + timeStamp, + includeTime, + typeOptionValue, +}: { + timeStamp: string; + includeTime?: boolean; + typeOptionValue: { + timeFormat: TimeFormat; + dateFormat: DateFormat; + }; +}) { + if (!typeOptionValue || !timeStamp) return null; + const timeFormat = getTimeFormat(typeOptionValue.timeFormat); + const dateFormat = getDateFormat(typeOptionValue.dateFormat); + const format = [dateFormat]; + + if (includeTime) { + format.push(timeFormat); + } + + return renderDate(timeStamp, format.join(' '), true); +} + +export const RIGHTWARDS_ARROW = '→'; + +export function getRowTimeString(field: YDatabaseField, timeStamp: string) { + const typeOption = getTypeOptions(field); + + const timeFormat = parseInt(typeOption.get(YjsDatabaseKey.time_format)) as TimeFormat; + const dateFormat = parseInt(typeOption.get(YjsDatabaseKey.date_format)) as DateFormat; + const includeTime = typeOption.get(YjsDatabaseKey.include_time); + + + return getDateTimeStr({ + timeStamp, + includeTime, + typeOptionValue: { + timeFormat, + dateFormat, + }, + }); +} + +export function getDateCellStr({ cell, field }: { cell: DateTimeCell; field: YDatabaseField }) { + const typeOptionMap = field.get(YjsDatabaseKey.type_option); + const typeOption = typeOptionMap.get(String(cell.fieldType)); + const timeFormat = parseInt(typeOption.get(YjsDatabaseKey.time_format)) as TimeFormat; + + const dateFormat = parseInt(typeOption.get(YjsDatabaseKey.date_format)) as DateFormat; + + const startData = cell.data || ''; + const includeTime = cell.includeTime; + + const typeOptionValue = { + timeFormat, + dateFormat, + }; + + const startDateTime = getDateTimeStr({ + timeStamp: startData, + includeTime, + typeOptionValue, + }); + + const endTimestamp = cell.endTimestamp; + + const isRange = cell.isRange; + + const endDateTime = + endTimestamp && isRange + ? getDateTimeStr({ + timeStamp: endTimestamp, + includeTime, + typeOptionValue, + }) + : null; + + return [startDateTime, endDateTime].filter(Boolean).join(` ${RIGHTWARDS_ARROW} `); +} + +export function isDate(input: string) { + const date = dayjs(input); + + return date.isValid(); +} + +export function safeParseTimestamp(input: string) { + if (/^\d+$/.test(input)) { + if (input.length >= 9 && input.length <= 10) { + return dayjs.unix(parseInt(input, 10)); + } else if (input.length >= 12 && input.length <= 13) { + return dayjs(parseInt(input, 10)); + } + } + + return dayjs(input); +} diff --git a/src/application/database-yjs/fields/index.ts b/src/application/database-yjs/fields/index.ts index 5505f0e4..7de2f9dc 100644 --- a/src/application/database-yjs/fields/index.ts +++ b/src/application/database-yjs/fields/index.ts @@ -1,3 +1,5 @@ +import { FieldType } from '@/application/database-yjs'; + export * from './type_option'; export * from './date'; export * from './number'; @@ -6,3 +8,38 @@ export * from './text'; export * from './checkbox'; export * from './checklist'; export * from './relation'; + +export function getFieldName (fieldType: FieldType) { + switch (fieldType) { + case FieldType.RichText: + return 'Text'; + case FieldType.Number: + return 'Numbers'; + case FieldType.Checkbox: + return 'Checkbox'; + case FieldType.SingleSelect: + return 'Select'; + case FieldType.MultiSelect: + return 'Multiselect'; + case FieldType.DateTime: + return 'Date'; + case FieldType.Checklist: + return 'Checklist'; + case FieldType.Relation: + return 'Relation'; + case FieldType.FileMedia: + return 'Files & media'; + case FieldType.URL: + return 'URL'; + case FieldType.LastEditedTime: + return 'Last modified'; + case FieldType.CreatedTime: + return 'Created at'; + case FieldType.AISummaries: + return 'AI Summary'; + case FieldType.AITranslations: + return 'AI Translate'; + default: + return 'Text'; + } +} \ No newline at end of file diff --git a/src/application/database-yjs/fields/media/parse.ts b/src/application/database-yjs/fields/media/parse.ts new file mode 100644 index 00000000..56b8d1e0 --- /dev/null +++ b/src/application/database-yjs/fields/media/parse.ts @@ -0,0 +1,64 @@ +import { getTypeOptions } from '@/application/database-yjs'; +import { FileMediaCellData } from '@/application/database-yjs/cell.type'; +import { YDatabaseField, YjsDatabaseKey } from '@/application/types'; +import * as Y from 'yjs'; + +export function parseToFilesMediaCellData (newItems: FileMediaCellData) { + const newData = new Y.Array(); + + newItems.forEach((item) => { + const itemStr = JSON.stringify(item); + + newData.push([itemStr]); + }); + + return newData; +} + +export function parseFileMediaTypeOptions (field: YDatabaseField) { + const content = getTypeOptions(field)?.get(YjsDatabaseKey.content); + + if (!content) return null; + + try { + return JSON.parse(content) as { + hide_file_names: boolean + }; + } catch (e) { + return null; + } +} + +export function updateFileName ({ data, fileId, newName }: { + data?: FileMediaCellData, + fileId: string, + newName: string +}) { + const newData = new Y.Array(); + + data?.forEach((item) => { + + if (item.id === fileId) { + item.name = newName; + } + + newData.push([JSON.stringify(item)]); + }); + + return newData; +} + +export function deleteFile ({ data, fileId }: { + data?: FileMediaCellData, + fileId: string, +}) { + const newData = new Y.Array(); + + data?.forEach((item) => { + if (item.id !== fileId) { + newData.push([JSON.stringify(item)]); + } + }); + + return newData; +} \ No newline at end of file diff --git a/src/application/database-yjs/fields/media/utils.ts b/src/application/database-yjs/fields/media/utils.ts new file mode 100644 index 00000000..700e53ca --- /dev/null +++ b/src/application/database-yjs/fields/media/utils.ts @@ -0,0 +1,34 @@ +import { FileMediaType } from '@/application/database-yjs/cell.type'; + +/** + * Get the media type of a file based on its filename. + * @param filename - The name or url of the file to check. + */ +export function getFileMediaType (filename: string) { + const imgExtensionRegex = /\.(gif|jpe?g|tiff?|png|webp|bmp)$/i; + const videoExtensionRegex = /\.(mp4|mov|avi|webm|flv|m4v|mpeg|h264)$/i; + const audioExtensionRegex = /\.(mp3|wav|ogg|flac|aac|wma|alac|aiff)$/i; + const documentExtensionRegex = /\.(pdf|doc|docx)$/i; + const archiveExtensionRegex = /\.(zip|tar|gz|7z|rar)$/i; + const textExtensionRegex = /\.(txt|md|html|css|js|json|xml|csv)$/i; + + const urlRegex = /^(https?:\/\/)/i; + + if (imgExtensionRegex.test(filename)) { + return FileMediaType.Image; + } else if (videoExtensionRegex.test(filename)) { + return FileMediaType.Video; + } else if (audioExtensionRegex.test(filename)) { + return FileMediaType.Audio; + } else if (documentExtensionRegex.test(filename)) { + return FileMediaType.Document; + } else if (archiveExtensionRegex.test(filename)) { + return FileMediaType.Archive; + } else if (textExtensionRegex.test(filename)) { + return FileMediaType.Text; + } else if (urlRegex.test(filename)) { + return FileMediaType.Link; + } else { + return FileMediaType.Other; + } +} \ No newline at end of file diff --git a/src/application/database-yjs/fields/number/EnhancedBigStats.ts b/src/application/database-yjs/fields/number/EnhancedBigStats.ts new file mode 100644 index 00000000..a9775a95 --- /dev/null +++ b/src/application/database-yjs/fields/number/EnhancedBigStats.ts @@ -0,0 +1,550 @@ +import Big from 'big.js'; +import { currencyFormaterMap } from '@/application/database-yjs'; +import { NumberFormat } from '@/application/database-yjs/fields/number/number.type'; + +/** + * Enhanced statistics calculator class with formatting capabilities + * Handles precise calculations using big.js and supports formatting in various currencies + */ +export class EnhancedBigStats { + private data: Big[]; + + /** + * Create a new statistics calculator instance + * @param data - Array of number strings + */ + constructor (data: string[] = []) { + this.validateData(data); + this.data = data.map(str => new Big(str)); + } + + /** + * Parse a user input string into a fully expanded decimal number string + * @param input - User input string or number which may contain various number formats + * @param format - Optional number format to apply to the output + * @returns Expanded decimal string without scientific notation, or null if parsing fails + */ + static parse (input: string | number, format?: NumberFormat): string | null { + if (!input || (typeof input !== 'string' && typeof input !== 'number')) { + return null; + } + + // Trim whitespace and remove currency symbols and separators + const cleaned = input.toString().trim() + // Remove all currency symbols, thousand separators, and other non-numeric characters + // except for digits, period, plus, minus, and 'e' for scientific notation + .replace(/[^0-9.e+-]/gi, '') + // Replace multiple periods with a single one (keep only the first) + .replace(/\.(?=.*\.)/g, ''); + + if (!cleaned) { + return null; + } + + try { + let bigNumber: Big; + + // Check if input is in scientific notation + const scientificMatch = /^([0-9]+\.?[0-9]*)[eE]([+-]?[0-9]+)/.exec(cleaned); + + if (scientificMatch) { + // Input is in scientific notation + const base = scientificMatch[1]; + const exponent = parseInt(scientificMatch[2], 10); + + // Create Big instance from scientific notation + bigNumber = new Big(`${base}e${exponent}`); + } else { + // Handle regular decimal numbers + const decimalMatch = /^([+-]?[0-9]+\.?[0-9]*)/.exec(cleaned); + + if (decimalMatch) { + bigNumber = new Big(decimalMatch[0]); + } else { + return null; + } + } + + // Fully expand the number to a decimal string without scientific notation + // This special conversion ensures no scientific notation is used + + // Handle sign separately to avoid issues with toString + const isNegative = bigNumber.lt(0); + const absoluteValue = bigNumber.abs(); + + // Convert to string and handle special cases + let expandedStr: string; + + // For very large or very small numbers, we need special handling + if (absoluteValue.e >= 21 || absoluteValue.e <= -7) { + // Get the coefficient and exponent + const c = absoluteValue.c; // Coefficient digits + const e = absoluteValue.e; // Exponent + + if (e >= 0) { + // Large number: add zeros to the right + expandedStr = c.join(''); + // Pad with zeros if needed + expandedStr = expandedStr.padEnd(e + 1, '0'); + } else { + // Small number: add zeros to the left and decimal point + expandedStr = '0.' + '0'.repeat(Math.abs(e) - 1) + c.join(''); + } + } else { + // For normal range numbers, Big.js toString already gives the right format + expandedStr = absoluteValue.toString(); + } + + const res = isNegative ? '-' + expandedStr : expandedStr; + + // If a format is specified, apply formatting + if (format !== undefined) { + return EnhancedBigStats.formatValue(res, format); + } + + // Add sign back if negative + return res; + } catch (error) { + // Return null if Big.js couldn't parse the value + return null; + } + } + + /** + * Format a number value using the specified number format + * @param numberValue - The number value to format (as string or Big) + * @param format - The number format to use + * @returns Formatted string representation + */ + static formatValue (numberValue: string | Big | number, format: NumberFormat = NumberFormat.Num): string { + // Convert input to string for processing + const valueStr = numberValue.toString(); + + // Use the formatter from the map + const formatter = currencyFormaterMap[format]; + + if (!formatter) { + return valueStr; + } + + try { + // Check if the value is an integer (no decimal point or all zeros after decimal) + const isInteger = !valueStr.includes('.') || + new Big(valueStr).mod(1).eq(0); + + if (isInteger) { + try { + // For integers, try to use BigInt for maximum precision + // Remove any decimal part that is all zeros + const cleanedInt = valueStr.includes('.') + ? valueStr.slice(0, valueStr.indexOf('.')) + : valueStr; + + return formatter(BigInt(cleanedInt)); + } catch (e) { + // If BigInt conversion fails (e.g., too large), fall back to Number + console.warn('BigInt conversion failed, falling back to Number:', e); + } + } + + // Check if the number is within safe JavaScript number range + return formatter(parseFloat(valueStr)); + } catch (error) { + console.error('Error in formatValue:', error); + // Return original string in case of error + return valueStr; + } + } + + /** + * Compare two number strings + * @param a - First number string + * @param b - Second number string + * @returns Comparison result: -1 if a < b, 1 if a > b, 0 if equal + */ + static compare (a: string, b: string): number { + const numA = new Big(a); + const numB = new Big(b); + + if (numA.lt(numB)) return -1; + if (numA.gt(numB)) return 1; + return 0; + } + + /** + * Validate input data + * @param data - Data to be validated + * @private + */ + private validateData (data: string[]): void { + if (!Array.isArray(data)) { + throw new Error('Data must be an array'); + } + + // Verify each element is a valid number string + for (const item of data) { + try { + // Try to create a Big instance to validate + new Big(item); + } catch (e) { + throw new Error(`Invalid number format: ${item}`); + } + } + } + + /** + * Calculate sum of the data + * @returns Total sum + */ + sum (): Big { + if (this.data.length === 0) return new Big(0); + + return this.data.reduce((acc, val) => acc.plus(val), new Big(0)); + } + + /** + * Calculate average of the data + * @returns Average value + */ + average (): Big { + if (this.data.length === 0) { + throw new Error('Cannot calculate average of empty dataset'); + } + + const sum = this.sum(); + + return sum.div(new Big(this.data.length)); + } + + /** + * Find maximum value in the data + * @returns Maximum value + */ + max (): Big { + if (this.data.length === 0) { + throw new Error('Cannot find maximum of empty dataset'); + } + + return this.data.reduce((max, val) => (val.gt(max) ? val : max), this.data[0]); + } + + /** + * Find minimum value in the data + * @returns Minimum value + */ + min (): Big { + if (this.data.length === 0) { + throw new Error('Cannot find minimum of empty dataset'); + } + + return this.data.reduce((min, val) => (val.lt(min) ? val : min), this.data[0]); + } + + /** + * Calculate median of the data + * @returns Median value + */ + median (): Big { + if (this.data.length === 0) { + throw new Error('Cannot calculate median of empty dataset'); + } + + // Create a sorted copy of the array + const sorted = [...this.data].sort((a, b) => { + if (a.lt(b)) return -1; + if (a.gt(b)) return 1; + return 0; + }); + + const len = sorted.length; + + if (len % 2 === 1) { + // Odd length: return middle element + return sorted[Math.floor(len / 2)]; + } else { + // Even length: return average of two middle elements + const mid1 = sorted[len / 2 - 1]; + const mid2 = sorted[len / 2]; + + return mid1.plus(mid2).div(2); + } + } + + /** + * Calculate variance of the data + * @returns Variance + */ + variance (): Big { + if (this.data.length <= 1) { + throw new Error('Need at least two data points to calculate variance'); + } + + const avg = this.average(); + const squaredDiffs = this.data.map(val => { + const diff = val.minus(avg); + + return diff.times(diff); + }); + + const sumSquaredDiffs = squaredDiffs.reduce((acc, val) => acc.plus(val), new Big(0)); + + return sumSquaredDiffs.div(new Big(this.data.length)); + } + + /** + * Calculate standard deviation of the data + * @returns Standard deviation + */ + standardDeviation (): Big { + const variance = this.variance(); + + // Initial guess + let x = new Big(variance.toString()); + + // Iterate 10 times (usually sufficient to converge to a precise approximation) + for (let i = 0; i < 10; i++) { + x = x.plus(variance.div(x)).div(2); + } + + return x; + } + + /** + * Return all calculation results with optional formatting + * @param decimalPlaces - Number of decimal places to keep in raw values + * @param format - Number format to use for formatted values + * @returns All statistical values with both raw and formatted representations + */ + getStats (decimalPlaces: number = 4, format: NumberFormat = NumberFormat.Num): { + count: number; + sum: { raw: string; formatted: string }; + average: { raw: string; formatted: string }; + median: { raw: string; formatted: string }; + min: { raw: string; formatted: string }; + max: { raw: string; formatted: string }; + range: { raw: string; formatted: string }; + variance: { raw: string; formatted: string } | null; + standardDeviation: { raw: string; formatted: string } | null; + } { + const DP = Big.DP; + + // Temporarily set precision + Big.DP = decimalPlaces; + + try { + const sum = this.sum(); + const average = this.data.length > 0 ? this.average() : new Big(0); + const median = this.data.length > 0 ? this.median() : new Big(0); + const min = this.data.length > 0 ? this.min() : new Big(0); + const max = this.data.length > 0 ? this.max() : new Big(0); + const range = this.data.length > 0 ? max.minus(min) : new Big(0); + + let variance = null; + let standardDeviation = null; + + if (this.data.length > 1) { + variance = this.variance(); + standardDeviation = this.standardDeviation(); + } + + return { + count: this.data.length, + sum: { + raw: sum.toString(), + formatted: EnhancedBigStats.formatValue(sum, format), + }, + average: { + raw: average.toString(), + formatted: EnhancedBigStats.formatValue(average, format), + }, + median: { + raw: median.toString(), + formatted: EnhancedBigStats.formatValue(median, format), + }, + min: { + raw: min.toString(), + formatted: EnhancedBigStats.formatValue(min, format), + }, + max: { + raw: max.toString(), + formatted: EnhancedBigStats.formatValue(max, format), + }, + range: { + raw: range.toString(), + formatted: EnhancedBigStats.formatValue(range, format), + }, + variance: variance ? { + raw: variance.toString(), + formatted: EnhancedBigStats.formatValue(variance, format), + } : null, + standardDeviation: standardDeviation ? { + raw: standardDeviation.toString(), + formatted: EnhancedBigStats.formatValue(standardDeviation, format), + } : null, + }; + } finally { + // Restore original precision setting + Big.DP = DP; + } + } + + /** + * Format a specific statistic with the given number format + * @param statMethod - Method that returns the statistic to format + * @param format - Number format to use + * @returns Formatted string representation of the statistic + */ + formatStat (statMethod: () => Big, format: NumberFormat): string { + try { + const value = statMethod.call(this); + + return EnhancedBigStats.formatValue(value, format); + } catch (error) { + return 'N/A'; + } + } + + /** + * Get formatted sum + * @param format - Number format to use + * @returns Formatted sum + */ + formattedSum (format: NumberFormat = NumberFormat.Num): string { + return this.formatStat(() => this.sum(), format); + } + + /** + * Get formatted average + * @param format - Number format to use + * @returns Formatted average + */ + formattedAverage (format: NumberFormat = NumberFormat.Num): string { + return this.formatStat(() => this.average(), format); + } + + /** + * Get formatted median + * @param format - Number format to use + * @returns Formatted median + */ + formattedMedian (format: NumberFormat = NumberFormat.Num): string { + return this.formatStat(() => this.median(), format); + } + + /** + * Get formatted minimum + * @param format - Number format to use + * @returns Formatted minimum + */ + formattedMin (format: NumberFormat = NumberFormat.Num): string { + return this.formatStat(() => this.min(), format); + } + + /** + * Get formatted maximum + * @param format - Number format to use + * @returns Formatted maximum + */ + formattedMax (format: NumberFormat = NumberFormat.Num): string { + return this.formatStat(() => this.max(), format); + } + + /** + * Add a new data point + * @param value - Number string to be added + */ + addDataPoint (value: string): void { + try { + const bigValue = new Big(value); + + this.data.push(bigValue); + } catch (e) { + throw new Error(`Invalid number format: ${value}`); + } + } + + /** + * Add a new data point by parsing a user input string + * @param input - User input string to parse and add + * @returns Boolean indicating if the addition was successful + */ + addParsedDataPoint (input: string): boolean { + const parsedValue = EnhancedBigStats.parse(input); + + if (parsedValue === null) { + return false; + } + + try { + this.addDataPoint(parsedValue); + return true; + } catch (e) { + return false; + } + } + + /** + * Clear the dataset + */ + clearData (): void { + this.data = []; + } + + /** + * Set a new dataset + * @param data - New dataset + */ + setData (data: string[]): void { + this.validateData(data); + this.data = data.map(str => new Big(str)); + } + + /** + * Set new data by parsing an array of user input strings + * @param inputs - Array of user input strings + * @returns Number of successfully parsed values + */ + setParsedData (inputs: string[]): number { + if (!Array.isArray(inputs)) { + return 0; + } + + const parsedValues: string[] = []; + let successCount = 0; + + for (const input of inputs) { + const parsedValue = EnhancedBigStats.parse(input); + + if (parsedValue !== null) { + parsedValues.push(parsedValue); + successCount++; + } + } + + if (parsedValues.length > 0) { + this.setData(parsedValues); + } + + return successCount; + } + + /** + * Get the raw data as Big instances + * @returns Array of Big numbers + */ + getRawData (): Big[] { + return [...this.data]; + } + + /** + * Get formatted data array + * @param format - Number format to use + * @returns Array of formatted values + */ + getFormattedData (format: NumberFormat = NumberFormat.Num): string[] { + return this.data.map(value => EnhancedBigStats.formatValue(value, format)); + } + +} + +export default EnhancedBigStats; \ No newline at end of file diff --git a/src/application/database-yjs/fields/number/__tests__/format.test.ts b/src/application/database-yjs/fields/number/__tests__/format.test.ts deleted file mode 100644 index f80b1db2..00000000 --- a/src/application/database-yjs/fields/number/__tests__/format.test.ts +++ /dev/null @@ -1,628 +0,0 @@ -import { currencyFormaterMap } from '../format'; -import { NumberFormat } from '../number.type'; -import { expect } from '@jest/globals'; - -const testCases = [0, 1, 0.5, 0.5666, 1000, 10000, 1000000, 10000000, 1000000.0]; -describe('currencyFormaterMap', () => { - test('should return the correct formatter for Num', () => { - const formater = currencyFormaterMap[NumberFormat.Num]; - const result = ['0', '1', '0.5', '0.5666', '1,000', '10,000', '1,000,000', '10,000,000', '1,000,000']; - testCases.forEach((testCase) => { - expect(formater(testCase)).toBe(result[testCases.indexOf(testCase)]); - }); - }); - - test('should return the correct formatter for Percent', () => { - const formater = currencyFormaterMap[NumberFormat.Percent]; - const result = ['0%', '1%', '0.5%', '0.57%', '1,000%', '10,000%', '1,000,000%', '10,000,000%', '1,000,000%']; - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for USD', () => { - const formater = currencyFormaterMap[NumberFormat.USD]; - const result = ['$0', '$1', '$0.5', '$0.57', '$1,000', '$10,000', '$1,000,000', '$10,000,000', '$1,000,000']; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for CanadianDollar', () => { - const formater = currencyFormaterMap[NumberFormat.CanadianDollar]; - const result = [ - 'CA$0', - 'CA$1', - 'CA$0.5', - 'CA$0.57', - 'CA$1,000', - 'CA$10,000', - 'CA$1,000,000', - 'CA$10,000,000', - 'CA$1,000,000', - ]; - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for EUR', () => { - const formater = currencyFormaterMap[NumberFormat.EUR]; - - const result = ['€0', '€1', '€0,5', '€0,57', '€1.000', '€10.000', '€1.000.000', '€10.000.000', '€1.000.000']; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for Pound', () => { - const formater = currencyFormaterMap[NumberFormat.Pound]; - - const result = ['£0', '£1', '£0.5', '£0.57', '£1,000', '£10,000', '£1,000,000', '£10,000,000', '£1,000,000']; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for Yen', () => { - const formater = currencyFormaterMap[NumberFormat.Yen]; - - const result = [ - '¥0', - '¥1', - '¥0.5', - '¥0.57', - '¥1,000', - '¥10,000', - '¥1,000,000', - '¥10,000,000', - '¥1,000,000', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for Ruble', () => { - const formater = currencyFormaterMap[NumberFormat.Ruble]; - - const result = [ - '0 RUB', - '1 RUB', - '0,5 RUB', - '0,57 RUB', - '1 000 RUB', - '10 000 RUB', - '1 000 000 RUB', - '10 000 000 RUB', - '1 000 000 RUB', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for Rupee', () => { - const formater = currencyFormaterMap[NumberFormat.Rupee]; - - const result = ['₹0', '₹1', '₹0.5', '₹0.57', '₹1,000', '₹10,000', '₹10,00,000', '₹1,00,00,000', '₹10,00,000']; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for Won', () => { - const formater = currencyFormaterMap[NumberFormat.Won]; - - const result = ['₩0', '₩1', '₩0.5', '₩0.57', '₩1,000', '₩10,000', '₩1,000,000', '₩10,000,000', '₩1,000,000']; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for Yuan', () => { - const formater = currencyFormaterMap[NumberFormat.Yuan]; - - const result = [ - 'CN¥0', - 'CN¥1', - 'CN¥0.5', - 'CN¥0.57', - 'CN¥1,000', - 'CN¥10,000', - 'CN¥1,000,000', - 'CN¥10,000,000', - 'CN¥1,000,000', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for Real', () => { - const formater = currencyFormaterMap[NumberFormat.Real]; - - const result = [ - 'R$ 0', - 'R$ 1', - 'R$ 0,5', - 'R$ 0,57', - 'R$ 1.000', - 'R$ 10.000', - 'R$ 1.000.000', - 'R$ 10.000.000', - 'R$ 1.000.000', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for Lira', () => { - const formater = currencyFormaterMap[NumberFormat.Lira]; - - const result = [ - 'TRY 0', - 'TRY 1', - 'TRY 0,5', - 'TRY 0,57', - 'TRY 1.000', - 'TRY 10.000', - 'TRY 1.000.000', - 'TRY 10.000.000', - 'TRY 1.000.000', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for Rupiah', () => { - const formater = currencyFormaterMap[NumberFormat.Rupiah]; - - const result = [ - 'IDR 0', - 'IDR 1', - 'IDR 0,5', - 'IDR 0,57', - 'IDR 1.000', - 'IDR 10.000', - 'IDR 1.000.000', - 'IDR 10.000.000', - 'IDR 1.000.000', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for Franc', () => { - const formater = currencyFormaterMap[NumberFormat.Franc]; - - const result = [ - 'CHF 0', - 'CHF 1', - 'CHF 0.5', - 'CHF 0.57', - `CHF 1’000`, - `CHF 10’000`, - `CHF 1’000’000`, - `CHF 10’000’000`, - `CHF 1’000’000`, - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for HongKongDollar', () => { - const formater = currencyFormaterMap[NumberFormat.HongKongDollar]; - - const result = [ - 'HK$0', - 'HK$1', - 'HK$0.5', - 'HK$0.57', - 'HK$1,000', - 'HK$10,000', - 'HK$1,000,000', - 'HK$10,000,000', - 'HK$1,000,000', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for NewZealandDollar', () => { - const formater = currencyFormaterMap[NumberFormat.NewZealandDollar]; - - const result = [ - 'NZ$0', - 'NZ$1', - 'NZ$0.5', - 'NZ$0.57', - 'NZ$1,000', - 'NZ$10,000', - 'NZ$1,000,000', - 'NZ$10,000,000', - 'NZ$1,000,000', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for Krona', () => { - const formater = currencyFormaterMap[NumberFormat.Krona]; - - const result = [ - '0 SEK', - '1 SEK', - '0,5 SEK', - '0,57 SEK', - '1 000 SEK', - '10 000 SEK', - '1 000 000 SEK', - '10 000 000 SEK', - '1 000 000 SEK', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - test('should return the correct formatter for NorwegianKrone', () => { - const formater = currencyFormaterMap[NumberFormat.NorwegianKrone]; - - const result = [ - 'NOK 0', - 'NOK 1', - 'NOK 0,5', - 'NOK 0,57', - 'NOK 1 000', - 'NOK 10 000', - 'NOK 1 000 000', - 'NOK 10 000 000', - 'NOK 1 000 000', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for MexicanPeso', () => { - const formater = currencyFormaterMap[NumberFormat.MexicanPeso]; - - const result = [ - 'MX$0', - 'MX$1', - 'MX$0.5', - 'MX$0.57', - 'MX$1,000', - 'MX$10,000', - 'MX$1,000,000', - 'MX$10,000,000', - 'MX$1,000,000', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for Rand', () => { - const formater = currencyFormaterMap[NumberFormat.Rand]; - - const result = [ - 'ZAR 0', - 'ZAR 1', - 'ZAR 0,5', - 'ZAR 0,57', - 'ZAR 1 000', - 'ZAR 10 000', - 'ZAR 1 000 000', - 'ZAR 10 000 000', - 'ZAR 1 000 000', - ]; - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for NewTaiwanDollar', () => { - const formater = currencyFormaterMap[NumberFormat.NewTaiwanDollar]; - - const result = [ - 'NT$0', - 'NT$1', - 'NT$0.5', - 'NT$0.57', - 'NT$1,000', - 'NT$10,000', - 'NT$1,000,000', - 'NT$10,000,000', - 'NT$1,000,000', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for DanishKrone', () => { - const formater = currencyFormaterMap[NumberFormat.DanishKrone]; - - const result = [ - '0 DKK', - '1 DKK', - '0,5 DKK', - '0,57 DKK', - '1.000 DKK', - '10.000 DKK', - '1.000.000 DKK', - '10.000.000 DKK', - '1.000.000 DKK', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - test('should return the correct formatter for Baht', () => { - const formater = currencyFormaterMap[NumberFormat.Baht]; - - const result = [ - 'THB 0', - 'THB 1', - 'THB 0.5', - 'THB 0.57', - 'THB 1,000', - 'THB 10,000', - 'THB 1,000,000', - 'THB 10,000,000', - 'THB 1,000,000', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - test('should return the correct formatter for Forint', () => { - const formater = currencyFormaterMap[NumberFormat.Forint]; - - const result = [ - '0 HUF', - '1 HUF', - '0,5 HUF', - '0,57 HUF', - '1 000 HUF', - '10 000 HUF', - '1 000 000 HUF', - '10 000 000 HUF', - '1 000 000 HUF', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for Koruna', () => { - const formater = currencyFormaterMap[NumberFormat.Koruna]; - - const result = [ - '0 CZK', - '1 CZK', - '0,5 CZK', - '0,57 CZK', - '1 000 CZK', - '10 000 CZK', - '1 000 000 CZK', - '10 000 000 CZK', - '1 000 000 CZK', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for Shekel', () => { - const formater = currencyFormaterMap[NumberFormat.Shekel]; - - const result = [ - '‏0 ‏₪', - '‏1 ‏₪', - '‏0.5 ‏₪', - '‏0.57 ‏₪', - '‏1,000 ‏₪', - '‏10,000 ‏₪', - '‏1,000,000 ‏₪', - '‏10,000,000 ‏₪', - '‏1,000,000 ‏₪', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - test('should return the correct formatter for ChileanPeso', () => { - const formater = currencyFormaterMap[NumberFormat.ChileanPeso]; - - const result = [ - 'CLP 0', - 'CLP 1', - 'CLP 0,5', - 'CLP 0,57', - 'CLP 1.000', - 'CLP 10.000', - 'CLP 1.000.000', - 'CLP 10.000.000', - 'CLP 1.000.000', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - test('should return the correct formatter for PhilippinePeso', () => { - const formater = currencyFormaterMap[NumberFormat.PhilippinePeso]; - - const result = ['₱0', '₱1', '₱0.5', '₱0.57', '₱1,000', '₱10,000', '₱1,000,000', '₱10,000,000', '₱1,000,000']; - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - test('should return the correct formatter for Dirham', () => { - const formater = currencyFormaterMap[NumberFormat.Dirham]; - - const result = [ - '‏0 AED', - '‏1 AED', - '‏0.5 AED', - '‏0.57 AED', - '‏1,000 AED', - '‏10,000 AED', - '‏1,000,000 AED', - '‏10,000,000 AED', - '‏1,000,000 AED', - ]; - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - test('should return the correct formatter for ColombianPeso', () => { - const formater = currencyFormaterMap[NumberFormat.ColombianPeso]; - - const result = [ - 'COP 0', - 'COP 1', - 'COP 0,5', - 'COP 0,57', - 'COP 1.000', - 'COP 10.000', - 'COP 1.000.000', - 'COP 10.000.000', - 'COP 1.000.000', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - test('should return the correct formatter for Riyal', () => { - const formater = currencyFormaterMap[NumberFormat.Riyal]; - - const result = [ - 'SAR 0', - 'SAR 1', - 'SAR 0.5', - 'SAR 0.57', - 'SAR 1,000', - 'SAR 10,000', - 'SAR 1,000,000', - 'SAR 10,000,000', - 'SAR 1,000,000', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for Ringgit', () => { - const formater = currencyFormaterMap[NumberFormat.Ringgit]; - - const result = [ - 'RM 0', - 'RM 1', - 'RM 0.5', - 'RM 0.57', - 'RM 1,000', - 'RM 10,000', - 'RM 1,000,000', - 'RM 10,000,000', - 'RM 1,000,000', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for Leu', () => { - const formater = currencyFormaterMap[NumberFormat.Leu]; - - const result = [ - '0 RON', - '1 RON', - '0,5 RON', - '0,57 RON', - '1.000 RON', - '10.000 RON', - '1.000.000 RON', - '10.000.000 RON', - '1.000.000 RON', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for ArgentinePeso', () => { - const formater = currencyFormaterMap[NumberFormat.ArgentinePeso]; - - const result = [ - 'ARS 0', - 'ARS 1', - 'ARS 0,5', - 'ARS 0,57', - 'ARS 1.000', - 'ARS 10.000', - 'ARS 1.000.000', - 'ARS 10.000.000', - 'ARS 1.000.000', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); - - test('should return the correct formatter for UruguayanPeso', () => { - const formater = currencyFormaterMap[NumberFormat.UruguayanPeso]; - - const result = [ - 'UYU 0', - 'UYU 1', - 'UYU 0,5', - 'UYU 0,57', - 'UYU 1.000', - 'UYU 10.000', - 'UYU 1.000.000', - 'UYU 10.000.000', - 'UYU 1.000.000', - ]; - - testCases.forEach((testCase, index) => { - expect(formater(testCase)).toBe(result[index]); - }); - }); -}); diff --git a/src/application/database-yjs/fields/number/format.ts b/src/application/database-yjs/fields/number/format.ts index 61e0942b..a51fcac4 100644 --- a/src/application/database-yjs/fields/number/format.ts +++ b/src/application/database-yjs/fields/number/format.ts @@ -8,31 +8,168 @@ const commonProps = { useGrouping: true, }; -export const currencyFormaterMap: Record string> = { - [NumberFormat.Num]: (n: number) => +export const formats = [ + { + label: 'Number', + value: NumberFormat.Num, + }, + { + label: 'US dollar', + value: NumberFormat.USD, + }, + { + label: 'Canadian dollar', + value: NumberFormat.CanadianDollar, + }, + { + label: 'Euro', + value: NumberFormat.EUR, + }, + { + label: 'Pound', + value: NumberFormat.Pound, + }, + { + label: 'Yen', + value: NumberFormat.Yen, + }, + { + label: 'Ruble', + value: NumberFormat.Ruble, + }, + { + label: 'Rupee', + value: NumberFormat.Rupee, + }, + { + label: 'Won', + value: NumberFormat.Won, + }, + { + label: 'Yuan', + value: NumberFormat.Yuan, + }, + { + label: 'Real', + value: NumberFormat.Real, + }, + { + label: 'Lira', + value: NumberFormat.Lira, + }, + { + label: 'Rupiah', + value: NumberFormat.Rupiah, + }, + { + label: 'Franc', + value: NumberFormat.Franc, + }, + { + label: 'Hong Kong dollar', + value: NumberFormat.HongKongDollar, + }, + { + label: 'New Zealand dollar', + value: NumberFormat.NewZealandDollar, + }, + { + label: 'Krona', + value: NumberFormat.Krona, + }, + { + label: 'Norwegian krone', + value: NumberFormat.NorwegianKrone, + }, + { + label: 'Danish krone', + value: NumberFormat.DanishKrone, + }, + { + label: 'Baht', + value: NumberFormat.Baht, + }, + { + label: 'Forint', + value: NumberFormat.Forint, + }, + { + label: 'Koruna', + value: NumberFormat.Koruna, + }, + { + label: 'Shekel', + value: NumberFormat.Shekel, + }, + { + label: 'Cheilean peso', + value: NumberFormat.ChileanPeso, + }, + { + label: 'Philippine peso', + value: NumberFormat.PhilippinePeso, + }, + { + label: 'Dirham', + value: NumberFormat.Dirham, + }, + { + label: 'Colombian peso', + value: NumberFormat.ColombianPeso, + }, + { + label: 'Riyal', + value: NumberFormat.Riyal, + }, + { + label: 'Ringgit', + value: NumberFormat.Ringgit, + }, + { + label: 'Leu', + value: NumberFormat.Leu, + }, + { + label: 'Argentine peso', + value: NumberFormat.ArgentinePeso, + }, + { + label: 'Uruguayan peso', + value: NumberFormat.UruguayanPeso, + }, + { + label: 'Percent', + value: NumberFormat.Percent, + }, + +]; + +export const currencyFormaterMap: Record string> = { + [NumberFormat.Num]: (n: bigint | number) => new Intl.NumberFormat('en-US', { style: 'decimal', minimumFractionDigits: 0, maximumFractionDigits: 20, + useGrouping: false, }).format(n), - [NumberFormat.Percent]: (n: number) => + [NumberFormat.Percent]: (n: bigint | number) => new Intl.NumberFormat('en-US', { ...commonProps, style: 'decimal', }).format(n) + '%', - [NumberFormat.USD]: (n: number) => + [NumberFormat.USD]: (n: bigint | number) => new Intl.NumberFormat('en-US', { ...commonProps, currency: 'USD', }).format(n), - [NumberFormat.CanadianDollar]: (n: number) => + [NumberFormat.CanadianDollar]: (n: bigint | number) => new Intl.NumberFormat('en-CA', { ...commonProps, currency: 'CAD', }) .format(n) .replace('$', 'CA$'), - [NumberFormat.EUR]: (n: number) => { + [NumberFormat.EUR]: (n: bigint | number) => { const formattedAmount = new Intl.NumberFormat('de-DE', { ...commonProps, currency: 'EUR', @@ -44,17 +181,17 @@ export const currencyFormaterMap: Record string> = return `€${formattedAmount}`; }, - [NumberFormat.Pound]: (n: number) => + [NumberFormat.Pound]: (n: bigint | number) => new Intl.NumberFormat('en-GB', { ...commonProps, currency: 'GBP', }).format(n), - [NumberFormat.Yen]: (n: number) => + [NumberFormat.Yen]: (n: bigint | number) => new Intl.NumberFormat('ja-JP', { ...commonProps, currency: 'JPY', }).format(n), - [NumberFormat.Ruble]: (n: number) => + [NumberFormat.Ruble]: (n: bigint | number) => new Intl.NumberFormat('ru-RU', { ...commonProps, currency: 'RUB', @@ -62,31 +199,31 @@ export const currencyFormaterMap: Record string> = }) .format(n) .replaceAll(' ', ' '), - [NumberFormat.Rupee]: (n: number) => + [NumberFormat.Rupee]: (n: bigint | number) => new Intl.NumberFormat('hi-IN', { ...commonProps, currency: 'INR', }).format(n), - [NumberFormat.Won]: (n: number) => + [NumberFormat.Won]: (n: bigint | number) => new Intl.NumberFormat('ko-KR', { ...commonProps, currency: 'KRW', }).format(n), - [NumberFormat.Yuan]: (n: number) => + [NumberFormat.Yuan]: (n: bigint | number) => new Intl.NumberFormat('zh-CN', { ...commonProps, currency: 'CNY', }) .format(n) .replace('¥', 'CN¥'), - [NumberFormat.Real]: (n: number) => + [NumberFormat.Real]: (n: bigint | number) => new Intl.NumberFormat('pt-BR', { ...commonProps, currency: 'BRL', }) .format(n) .replaceAll(' ', ' '), - [NumberFormat.Lira]: (n: number) => + [NumberFormat.Lira]: (n: bigint | number) => new Intl.NumberFormat('tr-TR', { ...commonProps, currency: 'TRY', @@ -94,7 +231,7 @@ export const currencyFormaterMap: Record string> = }) .format(n) .replaceAll(' ', ' '), - [NumberFormat.Rupiah]: (n: number) => + [NumberFormat.Rupiah]: (n: bigint | number) => new Intl.NumberFormat('id-ID', { ...commonProps, currency: 'IDR', @@ -102,132 +239,132 @@ export const currencyFormaterMap: Record string> = }) .format(n) .replaceAll(' ', ' '), - [NumberFormat.Franc]: (n: number) => + [NumberFormat.Franc]: (n: bigint | number) => new Intl.NumberFormat('de-CH', { ...commonProps, currency: 'CHF', }) .format(n) .replaceAll(' ', ' '), - [NumberFormat.HongKongDollar]: (n: number) => + [NumberFormat.HongKongDollar]: (n: bigint | number) => new Intl.NumberFormat('zh-HK', { ...commonProps, currency: 'HKD', }).format(n), - [NumberFormat.NewZealandDollar]: (n: number) => + [NumberFormat.NewZealandDollar]: (n: bigint | number) => new Intl.NumberFormat('en-NZ', { ...commonProps, currency: 'NZD', }) .format(n) .replace('$', 'NZ$'), - [NumberFormat.Krona]: (n: number) => + [NumberFormat.Krona]: (n: bigint | number) => new Intl.NumberFormat('sv-SE', { ...commonProps, currency: 'SEK', currencyDisplay: 'code', }).format(n), - [NumberFormat.NorwegianKrone]: (n: number) => + [NumberFormat.NorwegianKrone]: (n: bigint | number) => new Intl.NumberFormat('nb-NO', { ...commonProps, currency: 'NOK', currencyDisplay: 'code', }).format(n), - [NumberFormat.MexicanPeso]: (n: number) => + [NumberFormat.MexicanPeso]: (n: bigint | number) => new Intl.NumberFormat('es-MX', { ...commonProps, currency: 'MXN', }) .format(n) .replace('$', 'MX$'), - [NumberFormat.Rand]: (n: number) => + [NumberFormat.Rand]: (n: bigint | number) => new Intl.NumberFormat('en-ZA', { ...commonProps, currency: 'ZAR', currencyDisplay: 'code', }).format(n), - [NumberFormat.NewTaiwanDollar]: (n: number) => + [NumberFormat.NewTaiwanDollar]: (n: bigint | number) => new Intl.NumberFormat('zh-TW', { ...commonProps, currency: 'TWD', }) .format(n) .replace('$', 'NT$'), - [NumberFormat.DanishKrone]: (n: number) => + [NumberFormat.DanishKrone]: (n: bigint | number) => new Intl.NumberFormat('da-DK', { ...commonProps, currency: 'DKK', currencyDisplay: 'code', }).format(n), - [NumberFormat.Baht]: (n: number) => + [NumberFormat.Baht]: (n: bigint | number) => new Intl.NumberFormat('th-TH', { ...commonProps, currency: 'THB', currencyDisplay: 'code', }).format(n), - [NumberFormat.Forint]: (n: number) => + [NumberFormat.Forint]: (n: bigint | number) => new Intl.NumberFormat('hu-HU', { ...commonProps, currency: 'HUF', currencyDisplay: 'code', }).format(n), - [NumberFormat.Koruna]: (n: number) => + [NumberFormat.Koruna]: (n: bigint | number) => new Intl.NumberFormat('cs-CZ', { ...commonProps, currency: 'CZK', currencyDisplay: 'code', }).format(n), - [NumberFormat.Shekel]: (n: number) => + [NumberFormat.Shekel]: (n: bigint | number) => new Intl.NumberFormat('he-IL', { ...commonProps, currency: 'ILS', }).format(n), - [NumberFormat.ChileanPeso]: (n: number) => + [NumberFormat.ChileanPeso]: (n: bigint | number) => new Intl.NumberFormat('es-CL', { ...commonProps, currency: 'CLP', currencyDisplay: 'code', }).format(n), - [NumberFormat.PhilippinePeso]: (n: number) => + [NumberFormat.PhilippinePeso]: (n: bigint | number) => new Intl.NumberFormat('fil-PH', { ...commonProps, currency: 'PHP', }).format(n), - [NumberFormat.Dirham]: (n: number) => + [NumberFormat.Dirham]: (n: bigint | number) => new Intl.NumberFormat('ar-AE', { ...commonProps, currency: 'AED', currencyDisplay: 'code', }).format(n), - [NumberFormat.ColombianPeso]: (n: number) => + [NumberFormat.ColombianPeso]: (n: bigint | number) => new Intl.NumberFormat('es-CO', { ...commonProps, currency: 'COP', currencyDisplay: 'code', }).format(n), - [NumberFormat.Riyal]: (n: number) => + [NumberFormat.Riyal]: (n: bigint | number) => new Intl.NumberFormat('en-US', { ...commonProps, currency: 'SAR', currencyDisplay: 'code', }).format(n), - [NumberFormat.Ringgit]: (n: number) => + [NumberFormat.Ringgit]: (n: bigint | number) => new Intl.NumberFormat('ms-MY', { ...commonProps, currency: 'MYR', }).format(n), - [NumberFormat.Leu]: (n: number) => + [NumberFormat.Leu]: (n: bigint | number) => new Intl.NumberFormat('ro-RO', { ...commonProps, currency: 'RON', }).format(n), - [NumberFormat.ArgentinePeso]: (n: number) => + [NumberFormat.ArgentinePeso]: (n: bigint | number) => new Intl.NumberFormat('es-AR', { ...commonProps, currency: 'ARS', currencyDisplay: 'code', }).format(n), - [NumberFormat.UruguayanPeso]: (n: number) => + [NumberFormat.UruguayanPeso]: (n: bigint | number) => new Intl.NumberFormat('es-UY', { ...commonProps, currency: 'UYU', diff --git a/src/application/database-yjs/fields/number/parse.ts b/src/application/database-yjs/fields/number/parse.ts index d96ea879..866e6200 100644 --- a/src/application/database-yjs/fields/number/parse.ts +++ b/src/application/database-yjs/fields/number/parse.ts @@ -2,9 +2,15 @@ import { YDatabaseField } from '@/application/types'; import { getTypeOptions } from '../type_option'; import { NumberFormat } from './number.type'; -export function parseNumberTypeOptions(field: YDatabaseField) { +export function parseNumberTypeOptions (field: YDatabaseField) { const numberTypeOption = getTypeOptions(field)?.toJSON(); + if (!numberTypeOption) { + return { + format: NumberFormat.Num, + }; + } + return { format: parseInt(numberTypeOption.format) as NumberFormat, }; diff --git a/src/application/database-yjs/fields/select-option/utils.ts b/src/application/database-yjs/fields/select-option/utils.ts new file mode 100644 index 00000000..8569f2af --- /dev/null +++ b/src/application/database-yjs/fields/select-option/utils.ts @@ -0,0 +1,42 @@ +import { FieldType, SelectOptionColor } from '@/application/database-yjs'; +import { YDatabaseCell, YjsDatabaseKey } from '@/application/types'; +import { nanoid } from 'nanoid'; +import * as Y from 'yjs'; + +export function createSelectOptionCell (fieldId: string, type: FieldType, data: string) { + const cell = new Y.Map() as YDatabaseCell; + + cell.set(YjsDatabaseKey.id, fieldId); + cell.set(YjsDatabaseKey.data, data); + cell.set(YjsDatabaseKey.field_type, Number(type)); + cell.set(YjsDatabaseKey.created_at, Date.now()); + cell.set(YjsDatabaseKey.last_modified, Date.now()); + + return cell; +} + +export function generateOptionId () { + return nanoid(6); +} + +export function getColorByOption (text: string): SelectOptionColor { + if (!text || text.length === 0) { + const colors = Object.values(SelectOptionColor); + + return colors[Math.floor(Math.random() * colors.length)]; + } + + let hash = 0; + + for (let i = 0; i < text.length; i++) { + hash = ((hash << 5) - hash) + text.charCodeAt(i); + hash = hash & hash; + } + + hash = Math.abs(hash); + + const colors = Object.values(SelectOptionColor); + const colorIndex = hash % colors.length; + + return colors[colorIndex]; +} \ No newline at end of file diff --git a/src/application/database-yjs/fields/text/utils.ts b/src/application/database-yjs/fields/text/utils.ts new file mode 100644 index 00000000..cd788ddb --- /dev/null +++ b/src/application/database-yjs/fields/text/utils.ts @@ -0,0 +1,13 @@ +import { FieldType } from '@/application/database-yjs'; +import { YDatabaseField, YjsDatabaseKey } from '@/application/types'; +import * as Y from 'yjs'; + +export function createTextField (id: string) { + const field = new Y.Map() as YDatabaseField; + + field.set(YjsDatabaseKey.name, 'Text'); + field.set(YjsDatabaseKey.id, id); + field.set(YjsDatabaseKey.type, FieldType.RichText); + + return field; +} diff --git a/src/application/database-yjs/fields/type_option.ts b/src/application/database-yjs/fields/type_option.ts index 11da9948..39a8a40e 100644 --- a/src/application/database-yjs/fields/type_option.ts +++ b/src/application/database-yjs/fields/type_option.ts @@ -1,7 +1,7 @@ import { YDatabaseField, YjsDatabaseKey } from '@/application/types'; import { FieldType } from '@/application/database-yjs'; -export function getTypeOptions(field: YDatabaseField) { +export function getTypeOptions (field: YDatabaseField) { const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType; return field?.get(YjsDatabaseKey.type_option)?.get(String(fieldType)); diff --git a/src/application/database-yjs/filter.ts b/src/application/database-yjs/filter.ts index e3cb188c..40f083fd 100644 --- a/src/application/database-yjs/filter.ts +++ b/src/application/database-yjs/filter.ts @@ -1,13 +1,8 @@ -import { - RowId, - YDatabaseFields, - YDatabaseFilter, - YDatabaseFilters, - YDatabaseRow, - YDoc, - YjsDatabaseKey, - YjsEditorKey, -} from '@/application/types'; +import dayjs from 'dayjs'; +import { every, filter, some } from 'lodash-es'; + +import { parseYDatabaseDateTimeCellToCell } from '@/application/database-yjs/cell.parse'; +import { DateTimeCell } from '@/application/database-yjs/cell.type'; import { FieldType } from '@/application/database-yjs/database.type'; import { CheckboxFilter, @@ -15,6 +10,7 @@ import { ChecklistFilter, ChecklistFilterCondition, DateFilter, + DateFilterCondition, NumberFilter, NumberFilterCondition, parseChecklistData, @@ -23,11 +19,23 @@ import { TextFilter, TextFilterCondition, } from '@/application/database-yjs/fields'; +import { EnhancedBigStats } from '@/application/database-yjs/fields/number/EnhancedBigStats'; import { Row } from '@/application/database-yjs/selector'; -import Decimal from 'decimal.js'; -import { every, filter, some } from 'lodash-es'; +import { + RowId, + YDatabaseField, + YDatabaseFields, + YDatabaseFilter, + YDatabaseFilters, + YDatabaseRow, + YDoc, + YjsDatabaseKey, + YjsEditorKey, +} from '@/application/types'; +import { isAfterOneDay, isTimestampBefore, isTimestampBetweenRange, isTimestampInSameDay } from '@/utils/time'; +import { getChecked } from '@/application/database-yjs/fields/checkbox/utils'; -export function parseFilter (fieldType: FieldType, filter: YDatabaseFilter) { +export function parseFilter(fieldType: FieldType, filter: YDatabaseFilter) { const fieldId = filter.get(YjsDatabaseKey.field_id); const filterType = Number(filter.get(YjsDatabaseKey.filter_type)); const id = filter.get(YjsDatabaseKey.id); @@ -64,19 +72,38 @@ export function parseFilter (fieldType: FieldType, filter: YDatabaseFilter) { case FieldType.DateTime: case FieldType.CreatedTime: case FieldType.LastEditedTime: - return value as DateFilter; + try { + const data = JSON.parse(content) as DateFilter; + + return { + ...value, + ...data, + }; + } catch (e) { + console.error('Error parsing date filter content:', e); + return { + ...value, + timestamp: dayjs().startOf('day').unix(), + condition: DateFilterCondition.DateStartsOn, + }; + } } return value; } -function createPredicate (conditions: ((row: Row) => boolean)[]) { +function createPredicate(conditions: ((row: Row) => boolean)[]) { return function (item: Row) { return every(conditions, (condition) => condition(item)); }; } -export function filterBy (rows: Row[], filters: YDatabaseFilters, fields: YDatabaseFields, rowMetas: Record) { +export function filterBy( + rows: Row[], + filters: YDatabaseFilters, + fields: YDatabaseFields, + rowMetas: Record +) { const filterArray = filters.toArray(); if (filterArray.length === 0 || Object.keys(rowMetas).length === 0 || fields.size === 0) return rows; @@ -85,6 +112,8 @@ export function filterBy (rows: Row[], filters: YDatabaseFilters, fields: YDatab return (row: { id: string }) => { const fieldId = filter.get(YjsDatabaseKey.field_id); const field = fields.get(fieldId); + + if (!field) return true; const fieldType = Number(field.get(YjsDatabaseKey.type)); const rowId = row.id; const rowMeta = rowMetas[rowId]; @@ -98,22 +127,37 @@ export function filterBy (rows: Row[], filters: YDatabaseFilters, fields: YDatab const cells = meta.get(YjsDatabaseKey.cells); const cell = cells.get(fieldId); - if (!cell) return false; const { condition, content } = filterValue; + const cellData = (cell?.get(YjsDatabaseKey.data) as string) || ''; + switch (fieldType) { case FieldType.URL: case FieldType.RichText: - return textFilterCheck(cell.get(YjsDatabaseKey.data) as string, content, condition); + return textFilterCheck(cellData, content, condition); case FieldType.Number: - return numberFilterCheck(cell.get(YjsDatabaseKey.data) as string, content, condition); + return numberFilterCheck(cellData, content, condition); case FieldType.Checkbox: - return checkboxFilterCheck(cell.get(YjsDatabaseKey.data) as string, condition); + return checkboxFilterCheck(cellData, condition); case FieldType.SingleSelect: case FieldType.MultiSelect: - return selectOptionFilterCheck(cell.get(YjsDatabaseKey.data) as string, content, condition); + return selectOptionFilterCheck(cellData, content, condition); case FieldType.Checklist: - return checklistFilterCheck(cell.get(YjsDatabaseKey.data) as string, content, condition); + return checklistFilterCheck(cellData, content, condition); + case FieldType.DateTime: + return dateFilterCheck(cell ? parseYDatabaseDateTimeCellToCell(cell) : null, filterValue as DateFilter); + case FieldType.CreatedTime: { + const data = meta.get(YjsDatabaseKey.created_at); + + return rowTimeFilterCheck(data, filterValue as DateFilter); + } + + case FieldType.LastEditedTime: { + const data = meta.get(YjsDatabaseKey.last_modified); + + return rowTimeFilterCheck(data, filterValue as DateFilter); + } + default: return true; } @@ -124,12 +168,12 @@ export function filterBy (rows: Row[], filters: YDatabaseFilters, fields: YDatab return filter(rows, predicate); } -export function textFilterCheck (data: string, content: string, condition: TextFilterCondition) { +export function textFilterCheck(data: string, content: string, condition: TextFilterCondition) { switch (condition) { case TextFilterCondition.TextContains: - return data.includes(content); + return data.toLocaleLowerCase().includes(content.toLocaleLowerCase()); case TextFilterCondition.TextDoesNotContain: - return !data.includes(content); + return !data.toLocaleLowerCase().includes(content.toLocaleLowerCase()); case TextFilterCondition.TextIs: return data === content; case TextFilterCondition.TextIsNot: @@ -138,12 +182,16 @@ export function textFilterCheck (data: string, content: string, condition: TextF return data === ''; case TextFilterCondition.TextIsNotEmpty: return data !== ''; + case TextFilterCondition.TextEndsWith: + return data.toLocaleLowerCase().endsWith(content.toLocaleLowerCase()); + case TextFilterCondition.TextStartsWith: + return data.toLocaleLowerCase().startsWith(content.toLocaleLowerCase()); default: return false; } } -export function numberFilterCheck (data: string, content: string, condition: number) { +export function numberFilterCheck(data: string, content: string, condition: number) { if (isNaN(Number(data)) || isNaN(Number(content)) || data === '' || content === '') { if (condition === NumberFilterCondition.NumberIsEmpty) { return data === ''; @@ -156,39 +204,38 @@ export function numberFilterCheck (data: string, content: string, condition: num return false; } - const decimal = new Decimal(data).toNumber(); - const filterDecimal = new Decimal(content).toNumber(); + const res = EnhancedBigStats.compare(data, content); switch (condition) { case NumberFilterCondition.Equal: - return decimal === filterDecimal; + return res === 0; case NumberFilterCondition.NotEqual: - return decimal !== filterDecimal; + return res !== 0; case NumberFilterCondition.GreaterThan: - return decimal > filterDecimal; + return res > 0; case NumberFilterCondition.GreaterThanOrEqualTo: - return decimal >= filterDecimal; + return res >= 0; case NumberFilterCondition.LessThan: - return decimal < filterDecimal; + return res < 0; case NumberFilterCondition.LessThanOrEqualTo: - return decimal <= filterDecimal; + return res <= 0; default: return false; } } -export function checkboxFilterCheck (data: string, condition: number) { +export function checkboxFilterCheck(data: string, condition: number) { switch (condition) { case CheckboxFilterCondition.IsChecked: - return data === 'Yes'; + return getChecked(data); case CheckboxFilterCondition.IsUnChecked: - return data !== 'Yes'; + return !getChecked(data); default: return false; } } -export function checklistFilterCheck (data: string, content: string, condition: number) { +export function checklistFilterCheck(data: string, content: string, condition: number) { const percentage = parseChecklistData(data)?.percentage ?? 0; if (condition === ChecklistFilterCondition.IsComplete) { @@ -198,7 +245,92 @@ export function checklistFilterCheck (data: string, content: string, condition: return percentage !== 1; } -export function selectOptionFilterCheck (data: string, content: string, condition: number) { +export function rowTimeFilterCheck(data: string, filter: DateFilter) { + const { condition, end = '', start = '', timestamp = '' } = filter; + + switch (condition) { + case DateFilterCondition.DateStartIsEmpty: + return !data; + case DateFilterCondition.DateStartIsNotEmpty: + return !!data; + case DateFilterCondition.DateStartsOn: + return isTimestampInSameDay(data, timestamp.toString()); + case DateFilterCondition.DateStartsBefore: + if (!data) return false; + return isTimestampBefore(data, timestamp.toString()); + case DateFilterCondition.DateStartsAfter: + if (!data) return false; + return isAfterOneDay(data, timestamp.toString()); + case DateFilterCondition.DateStartsOnOrBefore: + if (!data) return false; + return isTimestampBefore(data, timestamp.toString()) || isTimestampInSameDay(data, timestamp.toString()); + case DateFilterCondition.DateStartsOnOrAfter: + if (!data) return false; + return isTimestampBefore(timestamp.toString(), data) || isTimestampInSameDay(timestamp.toString(), data); + case DateFilterCondition.DateStartsBetween: + if (!data) return false; + return isTimestampBetweenRange(data, start.toString(), end.toString()); + default: + return false; + } +} + +export function dateFilterCheck(cell: DateTimeCell | null, filter: DateFilter) { + const { condition, end = '', start = '', timestamp = '' } = filter; + + const { data = '', endTimestamp = '' } = cell || {}; + + switch (condition) { + case DateFilterCondition.DateEndIsEmpty: + case DateFilterCondition.DateStartIsEmpty: + return !data; + case DateFilterCondition.DateEndIsNotEmpty: + case DateFilterCondition.DateStartIsNotEmpty: + return !!data; + case DateFilterCondition.DateStartsOn: + return isTimestampInSameDay(data, timestamp.toString()); + case DateFilterCondition.DateEndsOn: + return isTimestampInSameDay(endTimestamp, timestamp.toString()); + case DateFilterCondition.DateStartsBefore: + if (!data) return false; + return isTimestampBefore(data, timestamp.toString()); + case DateFilterCondition.DateEndsBefore: + if (!data) return false; + return isTimestampBefore(endTimestamp, timestamp.toString()); + case DateFilterCondition.DateStartsAfter: + if (!data) return false; + return isAfterOneDay(data, timestamp.toString()); + case DateFilterCondition.DateEndsAfter: + if (!data) return false; + return isAfterOneDay(endTimestamp, timestamp.toString()); + case DateFilterCondition.DateStartsOnOrBefore: + if (!data) return false; + return isTimestampBefore(data, timestamp.toString()) || isTimestampInSameDay(data, timestamp.toString()); + case DateFilterCondition.DateEndsOnOrBefore: + if (!data) return false; + return ( + isTimestampBefore(endTimestamp, timestamp.toString()) || isTimestampInSameDay(endTimestamp, timestamp.toString()) + ); + case DateFilterCondition.DateStartsOnOrAfter: + if (!data) return false; + return isTimestampBefore(timestamp.toString(), data) || isTimestampInSameDay(timestamp.toString(), data); + case DateFilterCondition.DateEndsOnOrAfter: + if (!data) return false; + return ( + isTimestampBefore(timestamp.toString(), endTimestamp) || isTimestampInSameDay(timestamp.toString(), endTimestamp) + ); + case DateFilterCondition.DateStartsBetween: + if (!data) return false; + return isTimestampBetweenRange(data, start.toString(), end.toString()); + case DateFilterCondition.DateEndsBetween: + if (!data) return false; + return isTimestampBetweenRange(endTimestamp, start.toString(), end.toString()); + default: + return false; + } +} + +export function selectOptionFilterCheck(data: string, content: string, condition: number) { if (SelectOptionFilterCondition.OptionIsEmpty === condition) { return data === ''; } @@ -207,28 +339,296 @@ export function selectOptionFilterCheck (data: string, content: string, conditio return data !== ''; } - const selectedOptionIds = data.split(','); - const filterOptionIds = content.split(','); + const selectedOptionIds = data.split(',').filter((item) => item.trim() !== ''); + const filterOptionIds = content.split(',').filter((item) => item.trim() !== ''); switch (condition) { - // Ensure all filterOptionIds are included in selectedOptionIds case SelectOptionFilterCondition.OptionIs: - return every(filterOptionIds, (option) => selectedOptionIds.includes(option)); - - // Ensure none of the filterOptionIds are included in selectedOptionIds - case SelectOptionFilterCondition.OptionIsNot: - return every(filterOptionIds, (option) => !selectedOptionIds.includes(option)); - - // Ensure at least one of the filterOptionIds is included in selectedOptionIds case SelectOptionFilterCondition.OptionContains: + if (!content) return true; return some(filterOptionIds, (option) => selectedOptionIds.includes(option)); - // Ensure at least one of the filterOptionIds is not included in selectedOptionIds + case SelectOptionFilterCondition.OptionIsNot: case SelectOptionFilterCondition.OptionDoesNotContain: - return some(filterOptionIds, (option) => !selectedOptionIds.includes(option)); + if (!content) return true; + return every(filterOptionIds, (option) => !selectedOptionIds.includes(option)); // Default case, if no conditions match default: return false; } } + +// Return the default value for the filter +export function textFilterFillData(content: string, condition: number) { + switch (condition) { + case TextFilterCondition.TextContains: + case TextFilterCondition.TextStartsWith: + case TextFilterCondition.TextEndsWith: + return content; + case TextFilterCondition.TextDoesNotContain: + return ''; + case TextFilterCondition.TextIs: + return content; + case TextFilterCondition.TextIsNot: + return ''; + case TextFilterCondition.TextIsEmpty: + return ''; + case TextFilterCondition.TextIsNotEmpty: + return 'Untitled'; + default: + return ''; + } +} + +export function numberFilterFillData(content: string, condition: number) { + switch (condition) { + case NumberFilterCondition.Equal: + return content; + case NumberFilterCondition.NotEqual: + return ''; + case NumberFilterCondition.GreaterThan: + return Number(content) + 1; + case NumberFilterCondition.GreaterThanOrEqualTo: + return content; + case NumberFilterCondition.LessThan: + return Number(content) - 1; + case NumberFilterCondition.LessThanOrEqualTo: + return content; + default: + return ''; + } +} + +export function checkboxFilterFillData(condition: number) { + switch (condition) { + case CheckboxFilterCondition.IsChecked: + return 'Yes'; + case CheckboxFilterCondition.IsUnChecked: + return 'No'; + default: + return ''; + } +} + +export function checklistFilterFillData(content: string, condition: number) { + switch (condition) { + case ChecklistFilterCondition.IsComplete: + return JSON.stringify({ + options: [ + { + id: '1', + name: 'Todo', + }, + ], + selected_option_ids: ['1'], + }); + default: + return ''; + } +} + +export function selectOptionFilterFillData(content: string, condition: number) { + switch (condition) { + case SelectOptionFilterCondition.OptionIs: + return content; + case SelectOptionFilterCondition.OptionIsNot: + return ''; + case SelectOptionFilterCondition.OptionContains: + return content; + case SelectOptionFilterCondition.OptionDoesNotContain: + return ''; + case SelectOptionFilterCondition.OptionIsEmpty: + return ''; + case SelectOptionFilterCondition.OptionIsNotEmpty: + return content; + default: + return ''; + } +} + +export function dateFilterFillData(filter: YDatabaseFilter): { + data: string; + endTimestamp?: string; + includeTime?: boolean; + isRange?: boolean; +} { + const content = filter.get(YjsDatabaseKey.content); + const condition = Number(filter.get(YjsDatabaseKey.condition)); + const today = dayjs().startOf('day').unix().toString(); + + try { + const { + timestamp = today, + start = '', + end = '', + } = (JSON.parse(content) as { + timestamp?: string; + start?: string; + end?: string; + }) || {}; + + const beforeTimestamp = dayjs.unix(Number(timestamp)).subtract(1, 'day').startOf('day').unix().toString(); + const afterTimestamp = dayjs.unix(Number(timestamp)).add(1, 'day').startOf('day').unix().toString(); + + switch (condition) { + case DateFilterCondition.DateStartsOn: + return { + data: timestamp, + isRange: false, + }; + case DateFilterCondition.DateEndsOn: + return { + data: timestamp, + endTimestamp: timestamp, + isRange: true, + }; + case DateFilterCondition.DateStartsBefore: + return { + data: beforeTimestamp, + isRange: false, + }; + case DateFilterCondition.DateEndsBefore: + return { + data: beforeTimestamp, + endTimestamp: beforeTimestamp, + isRange: true, + }; + case DateFilterCondition.DateStartsAfter: + return { + data: afterTimestamp, + isRange: false, + }; + case DateFilterCondition.DateEndsAfter: + return { + data: afterTimestamp, + endTimestamp: afterTimestamp, + isRange: true, + }; + case DateFilterCondition.DateStartsOnOrBefore: + return { + data: timestamp, + isRange: false, + }; + case DateFilterCondition.DateEndsOnOrBefore: + return { + data: timestamp, + endTimestamp: timestamp, + isRange: true, + }; + case DateFilterCondition.DateStartsOnOrAfter: + return { + data: afterTimestamp, + isRange: false, + }; + case DateFilterCondition.DateEndsOnOrAfter: + return { + data: afterTimestamp, + endTimestamp: afterTimestamp, + isRange: true, + }; + case DateFilterCondition.DateStartsBetween: + return { + data: start || today, + isRange: false, + }; + case DateFilterCondition.DateEndsBetween: + return { + data: start || today, + endTimestamp: end || today, + isRange: true, + }; + case DateFilterCondition.DateStartIsEmpty: + case DateFilterCondition.DateEndIsEmpty: + return { + data: '', + isRange: false, + }; + case DateFilterCondition.DateStartIsNotEmpty: + case DateFilterCondition.DateEndIsNotEmpty: + return { + data: today, + endTimestamp: today, + isRange: true, + }; + default: + return { + data: today, + isRange: false, + }; + } + } catch (e) { + console.error('Error parsing date filter content:', e); + return { + data: today, + isRange: false, + }; + } +} + +export function filterFillData(filter: YDatabaseFilter, field: YDatabaseField) { + const content = filter.get(YjsDatabaseKey.content); + const condition = Number(filter.get(YjsDatabaseKey.condition)); + + const fieldType = Number(field.get(YjsDatabaseKey.type)); + + switch (fieldType) { + case FieldType.URL: + case FieldType.RichText: + return textFilterFillData(content, condition); + case FieldType.Number: + return numberFilterFillData(content, condition); + case FieldType.Checkbox: + return checkboxFilterFillData(condition); + case FieldType.SingleSelect: + case FieldType.MultiSelect: + return selectOptionFilterFillData(content, condition); + case FieldType.Checklist: + return checklistFilterFillData(content, condition); + default: + return null; + } +} + +export function getDefaultFilterCondition(fieldType: FieldType) { + switch (fieldType) { + case FieldType.RichText: + case FieldType.URL: + return { + condition: TextFilterCondition.TextContains, + content: '', + }; + case FieldType.Checkbox: + return { + condition: CheckboxFilterCondition.IsChecked, + }; + case FieldType.Checklist: + return { + condition: ChecklistFilterCondition.IsIncomplete, + }; + case FieldType.SingleSelect: + return { + condition: SelectOptionFilterCondition.OptionIs, + content: '', + }; + case FieldType.MultiSelect: + return { + condition: SelectOptionFilterCondition.OptionContains, + content: '', + }; + case FieldType.Number: + return { + condition: NumberFilterCondition.Equal, + value: '', + }; + case FieldType.DateTime: + case FieldType.CreatedTime: + case FieldType.LastEditedTime: + return { + condition: DateFilterCondition.DateStartsOn, + content: JSON.stringify({ + timestamp: dayjs().startOf('day').unix(), + }), + }; + } +} diff --git a/src/application/database-yjs/group.ts b/src/application/database-yjs/group.ts index 46174860..73ea261f 100644 --- a/src/application/database-yjs/group.ts +++ b/src/application/database-yjs/group.ts @@ -3,6 +3,7 @@ import { getCellData } from '@/application/database-yjs/const'; import { FieldType } from '@/application/database-yjs/database.type'; import { parseSelectOptionTypeOptions } from '@/application/database-yjs/fields'; import { Row } from '@/application/database-yjs/selector'; +import { getChecked } from '@/application/database-yjs/fields/checkbox/utils'; export function groupByField (rows: Row[], rowMetas: Record, field: YDatabaseField) { const fieldType = Number(field.get(YjsDatabaseKey.type)); @@ -19,14 +20,46 @@ export function groupByField (rows: Row[], rowMetas: Record, field: return; } +export function getGroupColumns (field: YDatabaseField) { + const fieldType = Number(field.get(YjsDatabaseKey.type)); + const isSelectOptionField = [FieldType.SingleSelect, FieldType.MultiSelect].includes(fieldType); + + if (isSelectOptionField) { + const typeOption = parseSelectOptionTypeOptions(field); + + if (!typeOption || typeOption.options.length === 0) { + return [{ id: field.get(YjsDatabaseKey.id) }]; + } + + const options = typeOption.options.map((option) => ({ + id: option.id, + })); + + return [ + { id: field.get(YjsDatabaseKey.id) }, + ...options, + ]; + } + + if (fieldType === FieldType.Checkbox) { + return [{ id: 'Yes' }, { id: 'No' }]; + } + +} + export function groupByCheckbox (rows: Row[], rowMetas: Record, field: YDatabaseField) { const fieldId = field.get(YjsDatabaseKey.id); const result = new Map(); rows.forEach((row) => { + // Skip if the row is not in the database + if (!rowMetas[row.id]) { + return; + } + const cellData = getCellData(row.id, fieldId, rowMetas); - const groupName = cellData === 'Yes' ? 'Yes' : 'No'; + const groupName = getChecked(cellData as string) ? 'Yes' : 'No'; const group = result.get(groupName) ?? []; group.push(row); @@ -50,6 +83,11 @@ export function groupBySelectOption (rows: Row[], rowMetas: Record, } rows.forEach((row) => { + // Skip if the row is not in the database + if (!rowMetas[row.id]) { + return; + } + const cellData = getCellData(row.id, fieldId, rowMetas); const selectedIds = (cellData as string)?.split(',') ?? []; diff --git a/src/application/database-yjs/index.ts b/src/application/database-yjs/index.ts index 1d5aa0ce..244faddd 100644 --- a/src/application/database-yjs/index.ts +++ b/src/application/database-yjs/index.ts @@ -4,3 +4,4 @@ export * from './context'; export * from './selector'; export * from './database.type'; export * from './const'; + diff --git a/src/application/database-yjs/row.ts b/src/application/database-yjs/row.ts new file mode 100644 index 00000000..e991bce6 --- /dev/null +++ b/src/application/database-yjs/row.ts @@ -0,0 +1,51 @@ +import { YDatabaseCells, YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey, YSharedRoot } from '@/application/types'; +import dayjs from 'dayjs'; +import * as Y from 'yjs'; + +export function initialDatabaseRow (rowId: string, databaseId: string, rowDoc: YDoc) { + const rowSharedRoot = rowDoc.getMap(YjsEditorKey.data_section) as YSharedRoot; + const row = new Y.Map() as YDatabaseRow; + const meta = new Y.Map(); + + const cells = new Y.Map() as YDatabaseCells; + + row.set(YjsDatabaseKey.id, rowId); + row.set(YjsDatabaseKey.database_id, databaseId); + row.set(YjsDatabaseKey.visibility, true); + row.set(YjsDatabaseKey.height, 36); + row.set(YjsDatabaseKey.created_at, String(dayjs().unix())); + row.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + row.set(YjsDatabaseKey.cells, cells); + + rowSharedRoot.set(YjsEditorKey.meta, meta); + rowSharedRoot.set(YjsEditorKey.database_row, row); +} + +export function getOptionsFromRow (rowDoc: YDoc, fieldId: string) { + const rowSharedRoot = rowDoc.getMap(YjsEditorKey.data_section) as YSharedRoot; + const row = rowSharedRoot.get(YjsEditorKey.database_row); + const options: string[] = []; + + if (!row) return options; + + const cells = row.get(YjsDatabaseKey.cells); + const cell = cells.get(fieldId); + + if (!cell) return options; + + const data = cell.get(YjsDatabaseKey.data); + + if (data && typeof data === 'string') { + const dataArray = data.split(','); + + dataArray.forEach(item => { + const option = item.trim(); + + if (option) { + options.push(option); + } + }); + } + + return options; +} \ No newline at end of file diff --git a/src/application/database-yjs/row_meta.ts b/src/application/database-yjs/row_meta.ts new file mode 100644 index 00000000..638edc8a --- /dev/null +++ b/src/application/database-yjs/row_meta.ts @@ -0,0 +1,83 @@ +import { metaIdFromRowId } from '@/application/database-yjs/const'; +import { RowMetaKey } from '@/application/database-yjs/database.type'; +import { RowCoverType } from '@/application/types'; +import type Y from 'yjs'; + +export function generateRowMeta (rowId: string, data: Record) { + const map = getMetaIdMap(rowId); + + const iconKey = map.get(RowMetaKey.IconId) ?? ''; + const coverKey = map.get(RowMetaKey.CoverId) ?? ''; + const isEmptyDocumentKey = map.get(RowMetaKey.IsDocumentEmpty) ?? ''; + const cover = data[RowMetaKey.CoverId] as string; // { data: string, cover_type: RowCoverType } + const icon = data[RowMetaKey.IconId] as string; + const isEmptyDocument = data[RowMetaKey.IsDocumentEmpty] as boolean; + const result: { + [key: string]: string | boolean | null; + } = {}; + + if (isEmptyDocument) { + Object.assign(result, { [isEmptyDocumentKey]: true }); + } + + if (cover) { + Object.assign(result, { [coverKey]: cover }); + } + + if (icon) { + Object.assign(result, { [iconKey]: icon }); + } + + return result; +} + +export const metaIdMapFromRowIdMap = new Map>(); + +export function getMetaIdMap (rowId: string) { + const hasMetaIdMap = metaIdMapFromRowIdMap.has(rowId); + + if (!hasMetaIdMap) { + const parser = metaIdFromRowId(rowId); + const map = new Map(); + + map.set(RowMetaKey.IconId, parser(RowMetaKey.IconId)); + map.set(RowMetaKey.CoverId, parser(RowMetaKey.CoverId)); + map.set(RowMetaKey.DocumentId, parser(RowMetaKey.DocumentId)); + map.set(RowMetaKey.IsDocumentEmpty, parser(RowMetaKey.IsDocumentEmpty)); + metaIdMapFromRowIdMap.set(rowId, map); + return map; + } + + return metaIdMapFromRowIdMap.get(rowId) as Map; +} + +export function getMetaJSON (rowId: string, meta: Y.Map) { + const metaKeyMap = getMetaIdMap(rowId); + + const iconKey = metaKeyMap.get(RowMetaKey.IconId) ?? ''; + const coverKey = metaKeyMap.get(RowMetaKey.CoverId) ?? ''; + const documentId = metaKeyMap.get(RowMetaKey.DocumentId) ?? ''; + const isEmptyDocumentKey = metaKeyMap.get(RowMetaKey.IsDocumentEmpty) ?? ''; + const metaJson = meta.toJSON(); + + const icon = (metaJson[iconKey] as string) || ''; + let cover = null; + + try { + cover = metaJson[coverKey] ? (JSON.parse(metaJson[coverKey]) as { + data: string, + cover_type: RowCoverType, + }) : null; + } catch (e) { + // do nothing + } + + const isEmptyDocument = metaJson[isEmptyDocumentKey] as boolean; + + return { + documentId, + cover: cover, + icon: icon, + isEmptyDocument: isEmptyDocument, + }; +} \ No newline at end of file diff --git a/src/application/database-yjs/selector.ts b/src/application/database-yjs/selector.ts index 65c98d18..54d7e40d 100644 --- a/src/application/database-yjs/selector.ts +++ b/src/application/database-yjs/selector.ts @@ -1,34 +1,52 @@ -import { - FieldId, RowCoverType, - SortId, - YDatabase, - YDatabaseField, YDatabaseMetas, YDatabaseRow, - YjsDatabaseKey, - YjsEditorKey, -} from '@/application/types'; -import { getCell, metaIdFromRowId, MIN_COLUMN_WIDTH } from '@/application/database-yjs/const'; +import dayjs from 'dayjs'; +import { debounce } from 'lodash-es'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { parseYDatabaseCellToCell } from '@/application/database-yjs/cell.parse'; +import { DateTimeCell } from '@/application/database-yjs/cell.type'; +import { getCell, MIN_COLUMN_WIDTH } from '@/application/database-yjs/const'; import { useDatabase, useDatabaseFields, useDatabaseView, - useRowDocMap, useDatabaseViewId, + useRowDocMap, } from '@/application/database-yjs/context'; +import { + DateFormat, + getDateCellStr, + getDateFormat, + getTimeFormat, + getTypeOptions, + parseSelectOptionTypeOptions, + SelectOption, + TimeFormat, +} from '@/application/database-yjs/fields'; import { filterBy, parseFilter } from '@/application/database-yjs/filter'; import { groupByField } from '@/application/database-yjs/group'; +import { getMetaJSON } from '@/application/database-yjs/row_meta'; import { sortBy } from '@/application/database-yjs/sort'; -import { parseYDatabaseCellToCell } from '@/application/database-yjs/cell.parse'; -import { DateTimeCell } from '@/application/database-yjs/cell.type'; -import dayjs from 'dayjs'; -import { debounce } from 'lodash-es'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { CalendarLayoutSetting, FieldType, FieldVisibility, Filter, RowMetaKey, SortCondition } from './database.type'; +import { + DatabaseViewLayout, + FieldId, + SortId, + YDatabase, + YDatabaseMetas, + YDatabaseRow, + YDoc, + YjsDatabaseKey, + YjsEditorKey, +} from '@/application/types'; +import { renderDate } from '@/utils/time'; + +import { CalendarLayoutSetting, FieldType, FieldVisibility, Filter, RowMeta, SortCondition } from './database.type'; export interface Column { fieldId: string; width: number; visibility: FieldVisibility; wrap?: boolean; + isPrimary: boolean; } export interface Row { @@ -38,7 +56,7 @@ export interface Row { const defaultVisible = [FieldVisibility.AlwaysShown, FieldVisibility.HideWhenEmpty]; -export function useDatabaseViewsSelector (_iidIndex: string, visibleViewIds?: string[]) { +export function useDatabaseViewsSelector(_iidIndex: string, visibleViewIds?: string[]) { const database = useDatabase(); const views = database?.get(YjsDatabaseKey.views); @@ -58,19 +76,21 @@ export function useDatabaseViewsSelector (_iidIndex: string, visibleViewIds?: st } >; - const viewsSorted = Object.entries(viewsObj).sort((a, b) => { - const [, viewA] = a; - const [, viewB] = b; + const viewsSorted = + visibleViewIds ?? + Object.entries(viewsObj) + .sort((a, b) => { + const [, viewA] = a; + const [, viewB] = b; - return Date.parse(viewB.created_at) - Date.parse(viewA.created_at); - }); + return Date.parse(viewB.created_at) - Date.parse(viewA.created_at); + }) + .map(([key]) => key); setViewIds( - viewsSorted - .map(([key]) => key) - .filter((id) => { - return !visibleViewIds || visibleViewIds.includes(id); - }), + viewsSorted.filter((id) => { + return !visibleViewIds || visibleViewIds.includes(id); + }) ); }; @@ -88,33 +108,62 @@ export function useDatabaseViewsSelector (_iidIndex: string, visibleViewIds?: st }; } -export function useFieldsSelector (visibilitys: FieldVisibility[] = defaultVisible) { - const viewId = useDatabaseViewId(); +export function useDatabaseViewLayout() { + const view = useDatabaseView(); + + const [layout, setLayout] = useState(null); + + useEffect(() => { + const observerEvent = () => { + const layoutValue = view?.get(YjsDatabaseKey.layout); + + if (layoutValue !== undefined) { + setLayout(Number(layoutValue) as DatabaseViewLayout); + } else { + setLayout(null); + } + }; + + observerEvent(); + + view?.observe(observerEvent); + return () => { + view?.unobserve(observerEvent); + }; + }, [view]); + + return layout; +} + +export function useFieldsSelector(visibilitys: FieldVisibility[] = defaultVisible) { + const view = useDatabaseView(); const database = useDatabase(); const [columns, setColumns] = useState([]); useEffect(() => { - if (!viewId) return; - const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + if (!view) return; const fields = database?.get(YjsDatabaseKey.fields); const fieldsOrder = view?.get(YjsDatabaseKey.field_orders); const fieldSettings = view?.get(YjsDatabaseKey.field_settings); const getColumns = () => { - if (!fields || !fieldsOrder || !fieldSettings) return []; + if (!fields || !fieldsOrder) return []; const fieldIds = (fieldsOrder.toJSON() as { id: string }[]).map((item) => item.id); return fieldIds .map((fieldId) => { - const setting = fieldSettings.get(fieldId); + const setting = fieldSettings?.get(fieldId); + const field = fields.get(fieldId); return { fieldId, + isPrimary: field?.get(YjsDatabaseKey.is_primary), width: parseInt(setting?.get(YjsDatabaseKey.width)) || MIN_COLUMN_WIDTH, visibility: Number( - setting?.get(YjsDatabaseKey.visibility) || FieldVisibility.AlwaysShown, + setting?.get(YjsDatabaseKey.visibility) || FieldVisibility.AlwaysShown ) as FieldVisibility, wrap: setting?.get(YjsDatabaseKey.wrap) ?? true, + fieldType: Number(field?.get(YjsDatabaseKey.type)) as FieldType, }; }) .filter((column) => { @@ -126,37 +175,114 @@ export function useFieldsSelector (visibilitys: FieldVisibility[] = defaultVisib setColumns(getColumns()); - fieldsOrder?.observe(observerEvent); - fieldSettings?.observe(observerEvent); + fieldsOrder?.observeDeep(observerEvent); + fieldSettings?.observeDeep(observerEvent); + fields?.observe(observerEvent); return () => { - fieldsOrder?.unobserve(observerEvent); - fieldSettings?.unobserve(observerEvent); + fieldsOrder?.unobserveDeep(observerEvent); + fieldSettings?.unobserveDeep(observerEvent); + fields?.unobserve(observerEvent); }; - }, [database, viewId, visibilitys]); + }, [database, view, visibilitys]); return columns; } -export function useFieldSelector (fieldId: string) { +export function useFieldType(fieldId: string) { + const database = useDatabase(); + const field = database?.get(YjsDatabaseKey.fields)?.get(fieldId); + const [fieldType, setFieldType] = useState(FieldType.RichText); + + useEffect(() => { + if (!field) return; + + const observerEvent = () => { + setFieldType(Number(field.get(YjsDatabaseKey.type)) as FieldType); + }; + + observerEvent(); + + field.observe(observerEvent); + + return () => { + field.unobserve(observerEvent); + }; + }, [database, field]); + + return fieldType; +} + +export function useFieldVisibility(fieldId: string) { + const view = useDatabaseView(); + const fieldSettings = view?.get(YjsDatabaseKey.field_settings); + const fieldSetting = fieldSettings?.get(fieldId); + + const [visibility, setVisibility] = useState( + Number(fieldSetting?.get(YjsDatabaseKey.visibility)) ?? FieldVisibility.AlwaysShown + ); + + useEffect(() => { + if (!view) return; + + const observerEvent = () => { + setVisibility(Number(fieldSetting?.get(YjsDatabaseKey.visibility)) ?? FieldVisibility.AlwaysShown); + }; + + observerEvent(); + + fieldSettings?.observeDeep(observerEvent); + + return () => { + fieldSettings?.unobserveDeep(observerEvent); + }; + }, [view, fieldId, fieldSettings, fieldSetting]); + + return visibility; +} + +export function useFieldWrap(fieldId: string) { + const view = useDatabaseView(); + const database = useDatabase(); + const fieldSettings = view?.get(YjsDatabaseKey.field_settings); + const fieldSetting = fieldSettings?.get(fieldId); + + const [wrap, setWrap] = useState(fieldSetting?.get(YjsDatabaseKey.wrap) ?? true); + + useEffect(() => { + if (!view) return; + + const observerEvent = () => { + setWrap(fieldSetting?.get(YjsDatabaseKey.wrap) ?? true); + }; + + observerEvent(); + + fieldSettings?.observeDeep(observerEvent); + + return () => { + fieldSettings?.unobserveDeep(observerEvent); + }; + }, [database, view, fieldId, fieldSettings, fieldSetting]); + + return wrap; +} + +export function useFieldSelector(fieldId: string) { const database = useDatabase(); - const [field, setField] = useState(null); const [clock, setClock] = useState(0); + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); useEffect(() => { if (!database) return; - - const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); - - setField(field || null); const observerEvent = () => setClock((prev) => prev + 1); - field?.observe(observerEvent); + field?.observeDeep(observerEvent); return () => { - field?.unobserve(observerEvent); + field?.unobserveDeep(observerEvent); }; - }, [database, fieldId]); + }, [database, field, fieldId]); return { field, @@ -164,10 +290,10 @@ export function useFieldSelector (fieldId: string) { }; } -export function useFiltersSelector () { +export function useFiltersSelector() { const database = useDatabase(); const viewId = useDatabaseViewId(); - const [filters, setFilters] = useState([]); + const [filters, setFilters] = useState<{ id: string; fieldId: string }[]>([]); useEffect(() => { if (!viewId) return; @@ -177,12 +303,19 @@ export function useFiltersSelector () { if (!filterOrders) return; const getFilters = () => { - return (filterOrders.toJSON() as { id: string }[]).map((item) => item.id); + return (filterOrders.toJSON() as { id: string; field_id: string }[]).map((item) => { + return { + id: item.id, + fieldId: item.field_id, + }; + }); }; - const observerEvent = () => setFilters(getFilters()); + const observerEvent = () => { + setFilters(getFilters()); + }; - setFilters(getFilters()); + observerEvent(); filterOrders.observe(observerEvent); @@ -194,7 +327,7 @@ export function useFiltersSelector () { return filters; } -export function useFilterSelector (filterId: string) { +export function useFilterSelector(filterId: string) { const database = useDatabase(); const viewId = useDatabaseViewId(); const fields = database?.get(YjsDatabaseKey.fields); @@ -227,10 +360,10 @@ export function useFilterSelector (filterId: string) { return filterValue; } -export function useSortsSelector () { +export function useSortsSelector() { const database = useDatabase(); const viewId = useDatabaseViewId(); - const [sorts, setSorts] = useState([]); + const [sorts, setSorts] = useState<{ id: string; fieldId: string }[]>([]); useEffect(() => { if (!viewId) return; @@ -240,7 +373,12 @@ export function useSortsSelector () { if (!sortOrders) return; const getSorts = () => { - return (sortOrders.toJSON() as { id: string }[]).map((item) => item.id); + return (sortOrders.toJSON() as { id: string; field_id: string }[]).map((item) => { + return { + id: item.id, + fieldId: item.field_id, + }; + }); }; const observerEvent = () => setSorts(getSorts()); @@ -263,7 +401,7 @@ export interface Sort { id: SortId; } -export function useSortSelector (sortId: SortId) { +export function useSortSelector(sortId: SortId) { const database = useDatabase(); const viewId = useDatabaseViewId(); const [sortValue, setSortValue] = useState(null); @@ -296,7 +434,7 @@ export function useSortSelector (sortId: SortId) { return sortValue; } -export function useGroupsSelector () { +export function useGroupsSelector() { const database = useDatabase(); const viewId = useDatabaseViewId(); const [groups, setGroups] = useState([]); @@ -310,17 +448,17 @@ export function useGroupsSelector () { if (!groupOrders) return; const getGroups = () => { - return (groupOrders.toJSON() as { id: string }[]).map((item) => item.id); + return groupOrders.toArray().map((item) => item.get(YjsDatabaseKey.id)); }; const observerEvent = () => setGroups(getGroups()); setGroups(getGroups()); - groupOrders.observe(observerEvent); + groupOrders.observeDeep(observerEvent); return () => { - groupOrders.unobserve(observerEvent); + groupOrders.unobserveDeep(observerEvent); }; }, [database, viewId]); @@ -332,7 +470,7 @@ export interface GroupColumn { visible: boolean; } -export function useGroup (groupId: string) { +export function useGroup(groupId: string) { const database = useDatabase(); const viewId = useDatabaseViewId(); const view = database?.get(YjsDatabaseKey.views)?.get(viewId); @@ -340,33 +478,29 @@ export function useGroup (groupId: string) { ?.get(YjsDatabaseKey.groups) ?.toArray() .find((group) => group.get(YjsDatabaseKey.id) === groupId); - const groupColumns = group?.get(YjsDatabaseKey.groups); const [fieldId, setFieldId] = useState(null); const [columns, setColumns] = useState([]); useEffect(() => { - if (!viewId) return; + if (!viewId || !group) return; const observerEvent = () => { - setFieldId(group?.get(YjsDatabaseKey.field_id) as string); + const groupFieldId = group.get(YjsDatabaseKey.field_id); + + setFieldId(groupFieldId); + const groupColumnsVisible = group.get(YjsDatabaseKey.groups); + const visibleArray = groupColumnsVisible?.toArray() || []; + + setColumns(visibleArray); }; observerEvent(); - group?.observe(observerEvent); - - const observerColumns = () => { - if (!groupColumns) return; - setColumns(groupColumns.toJSON()); - }; - - observerColumns(); - groupColumns?.observe(observerColumns); + group?.observeDeep(observerEvent); return () => { - group?.unobserve(observerEvent); - groupColumns?.unobserve(observerColumns); + group?.unobserveDeep(observerEvent); }; - }, [database, viewId, groupId, group, groupColumns]); + }, [database, viewId, groupId, group]); return { columns, @@ -374,10 +508,83 @@ export function useGroup (groupId: string) { }; } -export function useRowsByGroup (groupId: string) { +export function useBoardLayoutSettings() { + const view = useDatabaseView(); + const layoutSetting = view?.get(YjsDatabaseKey.layout_settings)?.get('1'); + const [isCollapsed, setIsCollapsed] = useState(true); + const [hideUnGroup, setHideUnGroup] = useState(true); + const groups = view?.get(YjsDatabaseKey.groups); + const [fieldId, setFieldId] = useState(null); + + useEffect(() => { + if (!layoutSetting) return; + + const observerEvent = () => { + setIsCollapsed(Boolean(layoutSetting?.get(YjsDatabaseKey.collapse_hidden_groups))); + setHideUnGroup(Boolean(layoutSetting?.get(YjsDatabaseKey.hide_ungrouped_column))); + }; + + observerEvent(); + layoutSetting.observe(observerEvent); + + return () => { + layoutSetting.unobserve(observerEvent); + }; + }, [view, layoutSetting]); + + useEffect(() => { + const observerEvent = () => { + const group = groups?.toArray()?.[0]; + + if (!group) return; + + const groupFieldId = group.get(YjsDatabaseKey.field_id); + + setFieldId(groupFieldId); + }; + + observerEvent(); + groups?.observeDeep(observerEvent); + + return () => { + groups?.unobserveDeep(observerEvent); + }; + }, [groups]); + + return { + isCollapsed, + hideUnGroup, + fieldId, + }; +} + +export function useGetBoardHiddenGroup(groupId: string) { + const { columns, fieldId } = useGroup(groupId); + const [hiddenColumns, setHiddenColumns] = useState([]); + const { hideUnGroup } = useBoardLayoutSettings(); + + useEffect(() => { + if (!columns) return; + + const hiddenColumns = columns.filter((column) => { + if (column.id === fieldId) return hideUnGroup; + + return !column.visible; + }); + + setHiddenColumns(hiddenColumns); + }, [columns, fieldId, hideUnGroup]); + + return { + hiddenColumns, + }; +} + +export function useRowsByGroup(groupId: string) { const { columns, fieldId } = useGroup(groupId); const rows = useRowDocMap(); const rowOrders = useRowOrdersSelector(); + const [visibleColumns, setVisibleColumns] = useState([]); const fields = useDatabaseFields(); const [notFound, setNotFound] = useState(false); @@ -399,6 +606,14 @@ export function useRowsByGroup (groupId: string) { return; } + const fieldType = Number(field.get(YjsDatabaseKey.type)) as FieldType; + + if (![FieldType.SingleSelect, FieldType.MultiSelect, FieldType.Checkbox].includes(fieldType)) { + setNotFound(true); + setGroupResult(newResult); + return; + } + const groupResult = groupByField(rowOrders, rows, field); if (!groupResult) { @@ -417,10 +632,24 @@ export function useRowsByGroup (groupId: string) { }; }, [fieldId, fields, rowOrders, rows]); - const visibleColumns = columns.filter((column) => { - if (column.id === fieldId) return !layoutSetting?.get(YjsDatabaseKey.hide_ungrouped_column); - return column.visible; - }); + useEffect(() => { + const observeEvent = () => { + const newVisibleColumns = columns.filter((column) => { + if (column.id === fieldId) return !layoutSetting?.get(YjsDatabaseKey.hide_ungrouped_column); + return column.visible; + }); + + setVisibleColumns(newVisibleColumns); + }; + + observeEvent(); + + layoutSetting?.observe(observeEvent); + + return () => { + layoutSetting?.unobserve(observeEvent); + }; + }, [layoutSetting, columns, fieldId]); return { fieldId, @@ -430,7 +659,7 @@ export function useRowsByGroup (groupId: string) { }; } -export function useRowOrdersSelector () { +export function useRowOrdersSelector() { const rows = useRowDocMap(); const [rowOrders, setRowOrders] = useState(); const view = useDatabaseView(); @@ -451,6 +680,7 @@ export function useRowOrdersSelector () { if (sorts?.length) { rowOrders = sortBy(originalRowOrders, sorts, fields, rows); + } if (filters?.length) { @@ -475,54 +705,93 @@ export function useRowOrdersSelector () { sorts?.observeDeep(throttleChange); filters?.observeDeep(throttleChange); fields?.observeDeep(throttleChange); + Object.values(rows || {}).forEach((rowDoc) => { + const rowSharedRoot = rowDoc.getMap(YjsEditorKey.data_section); + const databaseRow = rowSharedRoot.get(YjsEditorKey.database_row) as YDatabaseRow; + + databaseRow?.get(YjsDatabaseKey.cells)?.observeDeep(throttleChange); + }); return () => { view?.get(YjsDatabaseKey.row_orders)?.unobserveDeep(throttleChange); sorts?.unobserveDeep(throttleChange); filters?.unobserveDeep(throttleChange); fields?.unobserveDeep(throttleChange); + + Object.values(rows || {}).forEach((rowDoc) => { + const rowSharedRoot = rowDoc.getMap(YjsEditorKey.data_section); + const databaseRow = rowSharedRoot.get(YjsEditorKey.database_row) as YDatabaseRow; + + databaseRow?.get(YjsDatabaseKey.cells)?.unobserveDeep(throttleChange); + }); }; - }, [onConditionsChange, view, fields, filters, sorts]); + }, [onConditionsChange, view, fields, filters, sorts, rows]); return rowOrders; } -export function useRowDataSelector (rowId: string) { +export function useRowDataSelector(rowId: string) { const rowMap = useRowDocMap(); - const [row, setRow] = useState(null); + const rowDoc = rowMap?.[rowId]; - useEffect(() => { - const rowDoc = rowMap?.[rowId]; + const rowSharedRoot = rowDoc?.getMap(YjsEditorKey.data_section); - if (!rowDoc || !rowDoc.share.has(YjsEditorKey.data_section)) return; - const rowSharedRoot = rowDoc?.getMap(YjsEditorKey.data_section); - const row = rowSharedRoot?.get(YjsEditorKey.database_row); + const row = rowSharedRoot?.get(YjsEditorKey.database_row); - setRow(row); - }, [rowId, rowMap]); return { row, }; } -export function useCellSelector ({ rowId, fieldId }: { rowId: string; fieldId: string }) { +export function useCellSelector({ rowId, fieldId }: { rowId: string; fieldId: string }) { const { row } = useRowDataSelector(rowId); - const cell = row?.get(YjsDatabaseKey.cells)?.get(fieldId); + const cells = row?.get(YjsDatabaseKey.cells); - const [cellValue, setCellValue] = useState(() => (cell ? parseYDatabaseCellToCell(cell) : undefined)); + const cell = cells?.get(fieldId); + const [, setClock] = useState(0); + const [cellValue, setCellValue] = useState(() => { + return cell ? parseYDatabaseCellToCell(cell) : undefined; + }); useEffect(() => { - if (!cell) return; - setCellValue(parseYDatabaseCellToCell(cell)); - const observerEvent = () => setCellValue(parseYDatabaseCellToCell(cell)); + const observerEvent = () => { + setClock((prev) => prev + 1); + setCellValue(cell ? parseYDatabaseCellToCell(cell) : undefined); + }; - cell.observeDeep(observerEvent); + observerEvent(); + cell?.observeDeep(observerEvent); return () => { - cell.unobserveDeep(observerEvent); + cell?.unobserveDeep(observerEvent); }; }, [cell]); + useEffect(() => { + if (!cells) return; + + const observerEvent = () => { + const cell = cells.get(fieldId); + + if (!cell) { + setCellValue(undefined); + return; + } else { + const cellValue = parseYDatabaseCellToCell(cell); + + setCellValue(cellValue); + } + }; + + observerEvent(); + + cells.observe(observerEvent); + + return () => { + cells.unobserve(observerEvent); + }; + }, [cells, fieldId]); + return cellValue; } @@ -532,7 +801,7 @@ export interface CalendarEvent { id: string; } -export function useCalendarEventsSelector () { +export function useCalendarEventsSelector() { const setting = useCalendarLayoutSetting(); const filedId = setting.fieldId; const { field } = useFieldSelector(filedId); @@ -588,7 +857,7 @@ export function useCalendarEventsSelector () { return { events, emptyEvents }; } -export function useCalendarLayoutSetting () { +export function useCalendarLayoutSetting() { const view = useDatabaseView(); const layoutSetting = view?.get(YjsDatabaseKey.layout_settings)?.get('2'); const [setting, setSetting] = useState({ @@ -620,7 +889,7 @@ export function useCalendarLayoutSetting () { return setting; } -export function getPrimaryFieldId (database: YDatabase) { +export function getPrimaryFieldId(database: YDatabase) { const fields = database?.get(YjsDatabaseKey.fields); return Array.from(fields?.keys() || []).find((fieldId) => { @@ -628,7 +897,7 @@ export function getPrimaryFieldId (database: YDatabase) { }); } -export function usePrimaryFieldId () { +export function usePrimaryFieldId() { const database = useDatabase(); const [primaryFieldId, setPrimaryFieldId] = useState(null); @@ -639,42 +908,11 @@ export function usePrimaryFieldId () { return primaryFieldId; } -export interface RowMeta { - documentId: string; - cover: { - data: string, - cover_type: RowCoverType, - } | null; - icon: string; - isEmptyDocument: boolean; -} - -const metaIdMapFromRowIdMap = new Map>(); - -function getMetaIdMap (rowId: string) { - const hasMetaIdMap = metaIdMapFromRowIdMap.has(rowId); - - if (!hasMetaIdMap) { - const parser = metaIdFromRowId(rowId); - const map = new Map(); - - map.set(RowMetaKey.IconId, parser(RowMetaKey.IconId)); - map.set(RowMetaKey.CoverId, parser(RowMetaKey.CoverId)); - map.set(RowMetaKey.DocumentId, parser(RowMetaKey.DocumentId)); - map.set(RowMetaKey.IsDocumentEmpty, parser(RowMetaKey.IsDocumentEmpty)); - metaIdMapFromRowIdMap.set(rowId, map); - return map; - } - - return metaIdMapFromRowIdMap.get(rowId) as Map; -} - export const useRowMetaSelector = (rowId: string) => { const [meta, setMeta] = useState(); const rowMap = useRowDocMap(); const updateMeta = useCallback(() => { - const row = rowMap?.[rowId]; if (!row || !row.share.has(YjsEditorKey.data_section)) return; @@ -685,31 +923,9 @@ export const useRowMetaSelector = (rowId: string) => { if (!yMeta) return; - const metaKeyMap = getMetaIdMap(rowId); + const meta = getMetaJSON(rowId, yMeta); - const iconKey = metaKeyMap.get(RowMetaKey.IconId) ?? ''; - const coverKey = metaKeyMap.get(RowMetaKey.CoverId) ?? ''; - const documentId = metaKeyMap.get(RowMetaKey.DocumentId) ?? ''; - const isEmptyDocumentKey = metaKeyMap.get(RowMetaKey.IsDocumentEmpty) ?? ''; - const metaJson = yMeta.toJSON(); - - const icon = metaJson[iconKey]; - let cover = null; - - try { - cover = metaJson[coverKey] ? JSON.parse(metaJson[coverKey]) : null; - } catch (e) { - // do nothing - } - - const isEmptyDocument = metaJson[isEmptyDocumentKey]; - - setMeta({ - icon, - cover, - documentId, - isEmptyDocument, - }); + setMeta(meta); }, [rowId, rowMap]); useEffect(() => { @@ -731,3 +947,271 @@ export const useRowMetaSelector = (rowId: string) => { return meta; }; + +export const useFieldCellsSelector = (fieldId: string) => { + const rows = useRowOrdersSelector(); + const [cells, setCells] = useState | null>(null); + const rowMap = useRowDocMap(); + const cellObserverEventsRef = useRef<(() => void)[]>([]); + + useEffect(() => { + if (!rows || !rowMap) return; + + setCells(null); + + rows.forEach((row) => { + const rowDoc = rowMap?.[row.id]; + const rowSharedRoot = rowDoc?.getMap(YjsEditorKey.data_section); + + const databaseRow = rowSharedRoot?.get(YjsEditorKey.database_row) as YDatabaseRow; + + if (!databaseRow) return; + + const cells = databaseRow.get(YjsDatabaseKey.cells); + + const observerEvent = () => { + const cell = databaseRow.get(YjsDatabaseKey.cells)?.get(fieldId); + + if (!cell) { + setCells((prev) => { + const newMap = new Map(prev); + + newMap.set(row.id, ''); + + return newMap; + }); + return; + } + + const cellData = cell.get(YjsDatabaseKey.data); + + setCells((prev) => { + const newMap = new Map(prev); + + newMap.set(row.id, cellData); + + return newMap; + }); + }; + + observerEvent(); + cells?.observeDeep(observerEvent); + + cellObserverEventsRef.current.push(() => { + cells?.unobserveDeep(observerEvent); + }); + }); + + return () => { + cellObserverEventsRef.current.forEach((unobserverEvent) => { + unobserverEvent(); + }); + cellObserverEventsRef.current = []; + }; + }, [rows, rowMap, fieldId]); + + return { + cells, + }; +}; + +export const usePropertiesSelector = (isFilterHidden?: boolean) => { + const database = useDatabase(); + const view = useDatabaseView(); + + const fieldSettings = view?.get(YjsDatabaseKey.field_settings); + const fieldOrders = view?.get(YjsDatabaseKey.field_orders); + const fields = database?.get(YjsDatabaseKey.fields); + const [hiddenProperties, setHiddenProperties] = useState< + { + id: string; + visible: boolean; + name: string; + type: FieldType; + }[] + >([]); + const [properties, setProperties] = useState<{ id: string; visible: boolean; name: string; type: FieldType }[]>([]); + + useEffect(() => { + if (!fieldOrders) return; + + const observeEvent = () => { + const newProperties: { + id: string; + visible: boolean; + name: string; + type: FieldType; + }[] = []; + const hiddenProperties: { + id: string; + visible: boolean; + name: string; + type: FieldType; + }[] = []; + + fieldOrders.toArray().forEach((item) => { + const fieldSetting = fieldSettings?.get(item.id); + const visible = fieldSetting + ? Number(fieldSetting.get(YjsDatabaseKey.visibility)) !== FieldVisibility.AlwaysHidden + : true; + const field = fields?.get(item.id); + + if (!visible) { + hiddenProperties.push({ + id: item.id, + name: field?.get(YjsDatabaseKey.name) || '', + visible, + type: Number(field?.get(YjsDatabaseKey.type)) as FieldType, + }); + } + + if (isFilterHidden && !visible) { + return; + } else { + newProperties.push({ + id: item.id, + name: field?.get(YjsDatabaseKey.name) || '', + visible, + type: Number(field?.get(YjsDatabaseKey.type)) as FieldType, + }); + } + }); + + setProperties(newProperties); + setHiddenProperties(hiddenProperties); + }; + + observeEvent(); + + fields.observeDeep(observeEvent); + fieldOrders.observeDeep(observeEvent); + fieldSettings?.observeDeep(observeEvent); + + return () => { + fields.unobserveDeep(observeEvent); + fieldOrders.unobserveDeep(observeEvent); + fieldSettings?.unobserveDeep(observeEvent); + }; + }, [fieldOrders, fieldSettings, fields, isFilterHidden]); + + return { + properties, + hiddenProperties, + }; +}; + +export const useDateTimeCellString = (cell: DateTimeCell | undefined, fieldId: string) => { + const { field, clock } = useFieldSelector(fieldId); + + return useMemo(() => { + if (!cell) return null; + return getDateCellStr({ cell, field }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cell, field, clock]); +}; + +export const useRowTimeString = (rowId: string, fieldId: string, attrName: string) => { + const { field, clock } = useFieldSelector(fieldId); + + const typeOptionValue = useMemo(() => { + const typeOption = getTypeOptions(field); + + return { + timeFormat: parseInt(typeOption.get(YjsDatabaseKey.time_format)) as TimeFormat, + dateFormat: parseInt(typeOption.get(YjsDatabaseKey.date_format)) as DateFormat, + includeTime: typeOption.get(YjsDatabaseKey.include_time), + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [field, clock]); + + const getDateTimeStr = useCallback( + (timeStamp: string, includeTime?: boolean) => { + if (!typeOptionValue || !timeStamp) return null; + const timeFormat = getTimeFormat(typeOptionValue.timeFormat); + const dateFormat = getDateFormat(typeOptionValue.dateFormat); + const format = [dateFormat]; + + if (includeTime || typeOptionValue.includeTime) { + format.push(timeFormat); + } + + return renderDate(timeStamp, format.join(' '), true); + }, + [typeOptionValue] + ); + + const { row: rowData } = useRowDataSelector(rowId); + const [value, setValue] = useState(null); + + useEffect(() => { + if (!rowData) return; + const observeHandler = () => { + setValue(rowData.get(attrName)); + }; + + observeHandler(); + + rowData.observe(observeHandler); + return () => { + rowData.unobserve(observeHandler); + }; + }, [rowData, attrName]); + + const time = useMemo(() => { + if (!value) return null; + return getDateTimeStr(value); + }, [value, getDateTimeStr]); + + return time; +}; + +export const useSelectFieldOptions = (fieldId: string, searchValue?: string) => { + const { field, clock } = useFieldSelector(fieldId); + + return useMemo(() => { + const typeOption = field ? parseSelectOptionTypeOptions(field) : null; + + if (!typeOption) return [] as SelectOption[]; + + return typeOption.options.filter((option) => { + if (!searchValue) return true; + return option.name.toLowerCase().includes(searchValue.toLowerCase()); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [field, searchValue, clock]); +}; + +export function useRowPrimaryContentSelector(rowDoc: YDoc | null, primaryFieldId: string) { + const [primaryContent, setPrimaryContent] = useState(null); + + const rowSharedRoot = rowDoc?.getMap(YjsEditorKey.data_section); + const row = rowSharedRoot?.get(YjsEditorKey.database_row) as YDatabaseRow; + + useEffect(() => { + const observerEvent = () => { + if (!row) return; + + const cell = row.get(YjsDatabaseKey.cells)?.get(primaryFieldId); + + if (!cell) return; + + const cellValue = parseYDatabaseCellToCell(cell); + + if (cellValue) { + setPrimaryContent(cellValue.data as string); + } else { + setPrimaryContent(null); + } + }; + + observerEvent(); + + row?.observeDeep(observerEvent); + + return () => { + row?.unobserveDeep(observerEvent); + }; + }, [primaryFieldId, row, rowDoc]); + + return primaryContent; +} diff --git a/src/application/database-yjs/sort.ts b/src/application/database-yjs/sort.ts index 5e6e078d..66153a10 100644 --- a/src/application/database-yjs/sort.ts +++ b/src/application/database-yjs/sort.ts @@ -1,3 +1,8 @@ +import * as Y from 'yjs'; + +import { FieldType, SortCondition } from '@/application/database-yjs/database.type'; +import { parseChecklistData, parseSelectOptionCellData } from '@/application/database-yjs/fields'; +import { Row } from '@/application/database-yjs/selector'; import { RowId, YDatabaseField, @@ -8,29 +13,61 @@ import { YjsDatabaseKey, YjsEditorKey, } from '@/application/types'; -import { FieldType, SortCondition } from '@/application/database-yjs/database.type'; -import { parseChecklistData, parseSelectOptionCellData } from '@/application/database-yjs/fields'; -import { Row } from '@/application/database-yjs/selector'; -import { orderBy } from 'lodash-es'; +import { getChecked } from '@/application/database-yjs/fields/checkbox/utils'; -export function sortBy (rows: Row[], sorts: YDatabaseSorts, fields: YDatabaseFields, rowMetas: Record) { +type SortableValue = string | number | object | boolean | undefined; + +export function sortBy(rows: Row[], sorts: YDatabaseSorts, fields: YDatabaseFields, rowMetas: Record) { const sortArray = sorts.toArray(); if (sortArray.length === 0 || Object.keys(rowMetas).length === 0 || fields.size === 0) return rows; - const iteratees = sortArray.map((sort) => { - return (row: { id: string }) => { + + // Define collator for Unicode string comparison + // Can adjust parameters based on application needs, such as locale, sensitivity, etc. + const collator = new Intl.Collator(undefined, { + sensitivity: 'base', // Do not distinguish between uppercase and lowercase letters and accents + numeric: true, // Use numeric sorting, such as "2" before "10" + usage: 'sort', // Used specifically for sorting + }); + + // Create a function for comparison + const compare = (a: SortableValue, b: SortableValue, order: string): number => { + if (a === undefined && b === undefined) return 0; + // undefined value is placed at the end + if (a === undefined) return order === 'asc' ? 1 : -1; + if (b === undefined) return order === 'asc' ? -1 : 1; + + // Handle strings + if (typeof a === 'string' && typeof b === 'string') { + return order === 'asc' ? collator.compare(a, b) : collator.compare(b, a); + } + + // Handle other types + if (order === 'asc') { + return a < b ? -1 : a > b ? 1 : 0; + } else { + return a > b ? -1 : a < b ? 1 : 0; + } + }; + + // Prepare sort data, pre-calculate all values to avoid multiple calculations + const sortData = rows.map((row) => { + const values = sortArray.map((sort) => { const fieldId = sort.get(YjsDatabaseKey.field_id); + + if (!fieldId) return ''; + const field = fields.get(fieldId); const fieldType = Number(field.get(YjsDatabaseKey.type)); const rowId = row.id; const rowMeta = rowMetas[rowId]; - - const defaultData = parseCellDataForSort(field, ''); - const meta = rowMeta?.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database_row) as YDatabaseRow; + const defaultData = parseCellDataForSort(field, '', Number(sort.get(YjsDatabaseKey.condition))); + if (!meta) return defaultData; + if (fieldType === FieldType.LastEditedTime) { return meta.get(YjsDatabaseKey.last_modified); } @@ -41,40 +78,129 @@ export function sortBy (rows: Row[], sorts: YDatabaseSorts, fields: YDatabaseFie const cells = meta.get(YjsDatabaseKey.cells); const cell = cells.get(fieldId); + const data = cell?.get(YjsDatabaseKey.data); - if (!cell) return defaultData; + if (!cell || !data) return defaultData; + return parseCellDataForSort(field, data, Number(sort.get(YjsDatabaseKey.condition))); + }); - return parseCellDataForSort(field, cell.get(YjsDatabaseKey.data) ?? ''); - }; - }); - const orders = sortArray.map((sort) => { - const condition = Number(sort.get(YjsDatabaseKey.condition)); - - if (condition === SortCondition.Descending) return 'desc'; - return 'asc'; + return { row, values }; }); - return orderBy(rows, iteratees, orders); + sortData.sort((a, b) => { + for (let i = 0; i < sortArray.length; i++) { + const order = Number(sortArray[i].get(YjsDatabaseKey.condition)) === SortCondition.Descending ? 'desc' : 'asc'; + const result = compare(a.values[i], b.values[i], order); + + if (result !== 0) return result; + } + + return 0; + }); + + return sortData.map((item) => item.row); } -export function parseCellDataForSort (field: YDatabaseField, data: string | boolean | number | object) { +function dealWithUnicode(data: string) { + const hasUnicode = /[^\x20-\x7E]/.test(data); + + if (hasUnicode) { + const emojiRegex = /\p{Emoji}/u; + const emojiMatch = data.match(emojiRegex); + + if (emojiMatch && emojiMatch[0] !== data) { + const textOnly = data.replace(emojiRegex, '').trim(); + + return textOnly; + } else if (emojiMatch && emojiMatch[0] === data) { + return data; + } else { + return data; + } + } + + return data; +} + +export function parseCellDataForSort( + field: YDatabaseField, + data: string | boolean | number | object | Y.Array, + condition: SortCondition +) { const fieldType = Number(field.get(YjsDatabaseKey.type)); switch (fieldType) { case FieldType.RichText: - case FieldType.URL: - return data ? data : '\uFFFF'; + case FieldType.URL: { + if (data) { + return dealWithUnicode(data as string); + } + + if (condition === SortCondition.Descending) { + return '\u0000'; + } else { + return '\uFFFF'; + } + } + case FieldType.Number: - return data === 'string' && !isNaN(parseInt(data)) ? parseInt(data) : data; + if (data) { + return typeof data === 'string' && !isNaN(parseInt(data)) ? parseInt(data) : data; + } + + if (condition === SortCondition.Descending) { + return -Infinity; + } else { + return Infinity; + } + case FieldType.Checkbox: - return data === 'Yes'; + return getChecked(data as string); + case FieldType.SingleSelect: case FieldType.MultiSelect: - return parseSelectOptionCellData(field, data as string); - case FieldType.Checklist: - return parseChecklistData(data as string)?.percentage ?? 0; - case FieldType.DateTime: - return Number(data); + if (data) { + const parsedData = parseSelectOptionCellData(field, data as string); + + if (typeof parsedData === 'string') { + return dealWithUnicode(parsedData); + } + + return parsedData; + } + + if (condition === SortCondition.Descending) { + return '\u0000'; + } else { + return '\uFFFF'; + } + + case FieldType.Checklist: { + const percentage = parseChecklistData(data as string)?.percentage; + + if (percentage !== undefined) { + return percentage; + } + + if (condition === SortCondition.Descending) { + return -Infinity; + } else { + return Infinity; + } + } + + case FieldType.DateTime: { + if (data) { + return Number(data); + } + + if (condition === SortCondition.Descending) { + return -Infinity; + } else { + return Infinity; + } + } + case FieldType.Relation: return ''; } diff --git a/src/application/services/js-services/http/http_api.ts b/src/application/services/js-services/http/http_api.ts index 3744f5d5..6af12d06 100644 --- a/src/application/services/js-services/http/http_api.ts +++ b/src/application/services/js-services/http/http_api.ts @@ -1,3 +1,21 @@ +import { RepeatedChatMessage } from '@appflowyinc/ai-chat'; +import axios, { AxiosInstance } from 'axios'; +import dayjs from 'dayjs'; +import { omit } from 'lodash-es'; +import { nanoid } from 'nanoid'; + +import { GlobalComment, Reaction } from '@/application/comment.type'; +import { initGrantService, refreshToken } from '@/application/services/js-services/http/gotrue'; +import { blobToBytes } from '@/application/services/js-services/http/utils'; +import { AFCloudConfig } from '@/application/services/services.type'; +import { getTokenParsed, invalidToken } from '@/application/session/token'; +import { + Template, + TemplateCategory, + TemplateCategoryFormValues, + TemplateCreator, TemplateCreatorFormValues, TemplateSummary, + UploadTemplatePayload, +} from '@/application/template.type'; import { DatabaseId, FolderView, @@ -27,36 +45,24 @@ import { CreateWorkspacePayload, UpdateWorkspacePayload, PublishViewPayload, - UploadPublishNamespacePayload, UpdatePublishConfigPayload, + UploadPublishNamespacePayload, + UpdatePublishConfigPayload, + CreateFolderViewPayload, + GenerateAITranslateRowPayload, + GenerateAISummaryRowPayload, } from '@/application/types'; -import { GlobalComment, Reaction } from '@/application/comment.type'; -import { initGrantService, refreshToken } from '@/application/services/js-services/http/gotrue'; -import { blobToBytes } from '@/application/services/js-services/http/utils'; -import { AFCloudConfig } from '@/application/services/services.type'; -import { getTokenParsed, invalidToken } from '@/application/session/token'; -import { - Template, - TemplateCategory, - TemplateCategoryFormValues, - TemplateCreator, TemplateCreatorFormValues, TemplateSummary, - UploadTemplatePayload, -} from '@/application/template.type'; -import axios, { AxiosInstance } from 'axios'; -import dayjs from 'dayjs'; -import { omit } from 'lodash-es'; -import { nanoid } from 'nanoid'; import { notify } from '@/components/_shared/notify'; -import { RepeatedChatMessage } from '@appflowyinc/ai-chat'; + export * from './gotrue'; let axiosInstance: AxiosInstance | null = null; -export function getAxiosInstance() { +export function getAxiosInstance () { return axiosInstance; } -export function initAPIService(config: AFCloudConfig) { +export function initAPIService (config: AFCloudConfig) { if (axiosInstance) { return; } @@ -131,7 +137,7 @@ export function initAPIService(config: AFCloudConfig) { }); } -export async function signInWithUrl(url: string) { +export async function signInWithUrl (url: string) { const hash = new URL(url).hash; if (!hash) { @@ -168,7 +174,7 @@ export async function signInWithUrl(url: string) { } } -export async function verifyToken(accessToken: string) { +export async function verifyToken (accessToken: string) { const url = `/api/user/verify/${accessToken}`; const response = await axiosInstance?.get<{ code: number; @@ -187,7 +193,7 @@ export async function verifyToken(accessToken: string) { return Promise.reject(data); } -export async function getCurrentUser(): Promise { +export async function getCurrentUser (): Promise { const url = '/api/user/profile'; const response = await axiosInstance?.get<{ code: number; @@ -235,7 +241,7 @@ interface AFWorkspace { database_storage_id: string, } -function afWorkspace2Workspace(workspace: AFWorkspace): Workspace { +function afWorkspace2Workspace (workspace: AFWorkspace): Workspace { return { id: workspace.workspace_id, owner: { @@ -250,7 +256,7 @@ function afWorkspace2Workspace(workspace: AFWorkspace): Workspace { }; } -export async function openWorkspace(workspaceId: string) { +export async function openWorkspace (workspaceId: string) { const url = `/api/workspace/${workspaceId}/open`; const response = await axiosInstance?.put<{ code: number; @@ -264,7 +270,7 @@ export async function openWorkspace(workspaceId: string) { return Promise.reject(response?.data); } -export async function updateWorkspace(workspaceId: string, payload: UpdateWorkspacePayload) { +export async function updateWorkspace (workspaceId: string, payload: UpdateWorkspacePayload) { const url = `/api/workspace`; const response = await axiosInstance?.patch<{ code: number; @@ -286,7 +292,7 @@ export async function updateWorkspace(workspaceId: string, payload: UpdateWorksp return Promise.reject(data); } -export async function createWorkspace(payload: CreateWorkspacePayload) { +export async function createWorkspace (payload: CreateWorkspacePayload) { const url = '/api/workspace'; const response = await axiosInstance?.post<{ code: number; @@ -305,7 +311,7 @@ export async function createWorkspace(payload: CreateWorkspacePayload) { return Promise.reject(data); } -export async function getUserWorkspaceInfo(): Promise<{ +export async function getUserWorkspaceInfo (): Promise<{ user_id: string; selected_workspace: Workspace; workspaces: Workspace[]; @@ -339,7 +345,7 @@ export async function getUserWorkspaceInfo(): Promise<{ return Promise.reject(data); } -export async function publishView(workspaceId: string, viewId: string, payload?: PublishViewPayload) { +export async function publishView (workspaceId: string, viewId: string, payload?: PublishViewPayload) { const url = `/api/workspace/${workspaceId}/page-view/${viewId}/publish`; const response = await axiosInstance?.post<{ code: number; @@ -353,7 +359,7 @@ export async function publishView(workspaceId: string, viewId: string, payload?: return Promise.reject(response?.data); } -export async function unpublishView(workspaceId: string, viewId: string) { +export async function unpublishView (workspaceId: string, viewId: string) { const url = `/api/workspace/${workspaceId}/page-view/${viewId}/unpublish`; const response = await axiosInstance?.post<{ code: number; @@ -367,7 +373,7 @@ export async function unpublishView(workspaceId: string, viewId: string) { return Promise.reject(response?.data); } -export async function updatePublishNamespace(workspaceId: string, payload: UploadPublishNamespacePayload) { +export async function updatePublishNamespace (workspaceId: string, payload: UploadPublishNamespacePayload) { const url = `/api/workspace/${workspaceId}/publish-namespace`; const response = await axiosInstance?.put<{ code: number; @@ -381,7 +387,7 @@ export async function updatePublishNamespace(workspaceId: string, payload: Uploa return Promise.reject(response?.data); } -export async function getPublishViewMeta(namespace: string, publishName: string) { +export async function getPublishViewMeta (namespace: string, publishName: string) { const url = `/api/workspace/v1/published/${namespace}/${publishName}`; const response = await axiosInstance?.get<{ code: number; @@ -400,7 +406,7 @@ export async function getPublishViewMeta(namespace: string, publishName: string) return response?.data.data; } -export async function getPublishViewBlob(namespace: string, publishName: string) { +export async function getPublishViewBlob (namespace: string, publishName: string) { const url = `/api/workspace/published/${namespace}/${publishName}/blob`; const response = await axiosInstance?.get(url, { responseType: 'blob', @@ -409,7 +415,7 @@ export async function getPublishViewBlob(namespace: string, publishName: string) return blobToBytes(response?.data); } -export async function updateCollab(workspaceId: string, objectId: string, collabType: Types, docState: Uint8Array, context: { +export async function updateCollab (workspaceId: string, objectId: string, collabType: Types, docState: Uint8Array, context: { version_vector: number; }) { const url = `/api/workspace/v1/${workspaceId}/collab/${objectId}/web-update`; @@ -440,7 +446,7 @@ export async function updateCollab(workspaceId: string, objectId: string, collab return context; } -export async function getCollab(workspaceId: string, objectId: string, collabType: Types) { +export async function getCollab (workspaceId: string, objectId: string, collabType: Types) { const url = `/api/workspace/v1/${workspaceId}/collab/${objectId}`; const response = await axiosInstance?.get<{ code: number; @@ -466,7 +472,7 @@ export async function getCollab(workspaceId: string, objectId: string, collabTyp }; } -export async function getPageCollab(workspaceId: string, viewId: string) { +export async function getPageCollab (workspaceId: string, viewId: string) { const url = `/api/workspace/${workspaceId}/page-view/${viewId}`; const response = await axiosInstance?.get<{ code: number; @@ -500,7 +506,7 @@ export async function getPageCollab(workspaceId: string, viewId: string) { }; } -export async function getPublishView(publishNamespace: string, publishName: string) { +export async function getPublishView (publishNamespace: string, publishName: string) { const meta = await getPublishViewMeta(publishNamespace, publishName); const blob = await getPublishViewBlob(publishNamespace, publishName); @@ -537,7 +543,7 @@ export async function getPublishView(publishNamespace: string, publishName: stri } } -export async function updatePublishConfig(workspaceId: string, payload: UpdatePublishConfigPayload) { +export async function updatePublishConfig (workspaceId: string, payload: UpdatePublishConfigPayload) { const url = `/api/workspace/${workspaceId}/publish`; const response = await axiosInstance?.patch<{ code: number; @@ -551,7 +557,7 @@ export async function updatePublishConfig(workspaceId: string, payload: UpdatePu return Promise.reject(response?.data); } -export async function getPublishInfoWithViewId(viewId: string) { +export async function getPublishInfoWithViewId (viewId: string) { const url = `/api/workspace/v1/published-info/${viewId}`; const response = await axiosInstance?.get<{ code: number; @@ -576,7 +582,7 @@ export async function getPublishInfoWithViewId(viewId: string) { return Promise.reject(data); } -export async function getAppFavorites(workspaceId: string) { +export async function getAppFavorites (workspaceId: string) { const url = `/api/workspace/${workspaceId}/favorite`; const response = await axiosInstance?.get<{ code: number; @@ -595,7 +601,7 @@ export async function getAppFavorites(workspaceId: string) { return Promise.reject(data); } -export async function getAppTrash(workspaceId: string) { +export async function getAppTrash (workspaceId: string) { const url = `/api/workspace/${workspaceId}/trash`; const response = await axiosInstance?.get<{ code: number; @@ -614,7 +620,7 @@ export async function getAppTrash(workspaceId: string) { return Promise.reject(data); } -export async function getAppRecent(workspaceId: string) { +export async function getAppRecent (workspaceId: string) { const url = `/api/workspace/${workspaceId}/recent`; const response = await axiosInstance?.get<{ code: number; @@ -633,7 +639,7 @@ export async function getAppRecent(workspaceId: string) { return Promise.reject(data); } -export async function getAppOutline(workspaceId: string) { +export async function getAppOutline (workspaceId: string) { const url = `/api/workspace/${workspaceId}/folder?depth=10`; const response = await axiosInstance?.get<{ @@ -651,7 +657,7 @@ export async function getAppOutline(workspaceId: string) { return Promise.reject(data); } -export async function getView(workspaceId: string, viewId: string, depth: number = 1) { +export async function getView (workspaceId: string, viewId: string, depth: number = 1) { const url = `/api/workspace/${workspaceId}/folder?depth=${depth}&root_view_id=${viewId}`; const response = await axiosInstance?.get<{ code: number; @@ -668,7 +674,7 @@ export async function getView(workspaceId: string, viewId: string, depth: number return Promise.reject(data); } -export async function getPublishNamespace(workspaceId: string) { +export async function getPublishNamespace (workspaceId: string) { const url = `/api/workspace/${workspaceId}/publish-namespace`; const response = await axiosInstance?.get<{ code: number; @@ -685,7 +691,7 @@ export async function getPublishNamespace(workspaceId: string) { return Promise.reject(data); } -export async function getPublishHomepage(workspaceId: string) { +export async function getPublishHomepage (workspaceId: string) { const url = `/api/workspace/${workspaceId}/publish-default`; const response = await axiosInstance?.get<{ code: number; @@ -707,7 +713,7 @@ export async function getPublishHomepage(workspaceId: string) { return Promise.reject(data); } -export async function updatePublishHomepage(workspaceId: string, viewId: string) { +export async function updatePublishHomepage (workspaceId: string, viewId: string) { const url = `/api/workspace/${workspaceId}/publish-default`; const response = await axiosInstance?.put<{ code: number; @@ -723,7 +729,7 @@ export async function updatePublishHomepage(workspaceId: string, viewId: string) return Promise.reject(response?.data); } -export async function removePublishHomepage(workspaceId: string) { +export async function removePublishHomepage (workspaceId: string) { const url = `/api/workspace/${workspaceId}/publish-default`; const response = await axiosInstance?.delete<{ code: number; @@ -737,7 +743,7 @@ export async function removePublishHomepage(workspaceId: string) { return Promise.reject(response?.data); } -export async function getPublishOutline(publishNamespace: string) { +export async function getPublishOutline (publishNamespace: string) { const url = `/api/workspace/published-outline/${publishNamespace}`; const response = await axiosInstance?.get<{ code: number; @@ -754,7 +760,7 @@ export async function getPublishOutline(publishNamespace: string) { return Promise.reject(data); } -export async function getPublishViewComments(viewId: string): Promise { +export async function getPublishViewComments (viewId: string): Promise { const url = `/api/workspace/published-info/${viewId}/comment`; const response = await axiosInstance?.get<{ code: number; @@ -803,7 +809,7 @@ export async function getPublishViewComments(viewId: string): Promise> { +export async function getReactions (viewId: string, commentId?: string): Promise> { let url = `/api/workspace/published-info/${viewId}/reaction`; if (commentId) { @@ -854,7 +860,7 @@ export async function getReactions(viewId: string, commentId?: string): Promise< return Promise.reject(data); } -export async function createGlobalCommentOnPublishView(viewId: string, content: string, replyCommentId?: string) { +export async function createGlobalCommentOnPublishView (viewId: string, content: string, replyCommentId?: string) { const url = `/api/workspace/published-info/${viewId}/comment`; const response = await axiosInstance?.post<{ code: number; message: string }>(url, { content, @@ -868,7 +874,7 @@ export async function createGlobalCommentOnPublishView(viewId: string, content: return Promise.reject(response?.data.message); } -export async function deleteGlobalCommentOnPublishView(viewId: string, commentId: string) { +export async function deleteGlobalCommentOnPublishView (viewId: string, commentId: string) { const url = `/api/workspace/published-info/${viewId}/comment`; const response = await axiosInstance?.delete<{ code: number; message: string }>(url, { data: { @@ -883,7 +889,7 @@ export async function deleteGlobalCommentOnPublishView(viewId: string, commentId return Promise.reject(response?.data.message); } -export async function addReaction(viewId: string, commentId: string, reactionType: string) { +export async function addReaction (viewId: string, commentId: string, reactionType: string) { const url = `/api/workspace/published-info/${viewId}/reaction`; const response = await axiosInstance?.post<{ code: number; message: string }>(url, { comment_id: commentId, @@ -897,7 +903,7 @@ export async function addReaction(viewId: string, commentId: string, reactionTyp return Promise.reject(response?.data.message); } -export async function removeReaction(viewId: string, commentId: string, reactionType: string) { +export async function removeReaction (viewId: string, commentId: string, reactionType: string) { const url = `/api/workspace/published-info/${viewId}/reaction`; const response = await axiosInstance?.delete<{ code: number; message: string }>(url, { data: { @@ -913,7 +919,7 @@ export async function removeReaction(viewId: string, commentId: string, reaction return Promise.reject(response?.data.message); } -export async function getWorkspaces(): Promise { +export async function getWorkspaces (): Promise { const query = new URLSearchParams({ include_member_count: 'true', }); @@ -951,7 +957,7 @@ export interface WorkspaceFolder { children: WorkspaceFolder[]; } -function iterateFolder(folder: WorkspaceFolder): FolderView { +function iterateFolder (folder: WorkspaceFolder): FolderView { return { id: folder.view_id, name: folder.name, @@ -965,7 +971,7 @@ function iterateFolder(folder: WorkspaceFolder): FolderView { }; } -export async function getWorkspaceFolder(workspaceId: string): Promise { +export async function getWorkspaceFolder (workspaceId: string): Promise { const url = `/api/workspace/${workspaceId}/folder`; const response = await axiosInstance?.get<{ code: number; @@ -988,7 +994,7 @@ export interface DuplicatePublishViewPayload { dest_view_id: string; } -export async function duplicatePublishView(workspaceId: string, payload: DuplicatePublishViewPayload) { +export async function duplicatePublishView (workspaceId: string, payload: DuplicatePublishViewPayload) { const url = `/api/workspace/${workspaceId}/published-duplicate`; const res = await axiosInstance?.post<{ @@ -1006,7 +1012,7 @@ export async function duplicatePublishView(workspaceId: string, payload: Duplica return Promise.reject(res?.data.message); } -export async function createTemplate(template: UploadTemplatePayload) { +export async function createTemplate (template: UploadTemplatePayload) { const url = '/api/template-center/template'; const response = await axiosInstance?.post<{ code: number; @@ -1020,7 +1026,7 @@ export async function createTemplate(template: UploadTemplatePayload) { return Promise.reject(response?.data.message); } -export async function updateTemplate(viewId: string, template: UploadTemplatePayload) { +export async function updateTemplate (viewId: string, template: UploadTemplatePayload) { const url = `/api/template-center/template/${viewId}`; const response = await axiosInstance?.put<{ code: number; @@ -1034,7 +1040,7 @@ export async function updateTemplate(viewId: string, template: UploadTemplatePay return Promise.reject(response?.data.message); } -export async function getTemplates({ +export async function getTemplates ({ categoryId, nameContains, }: { @@ -1065,7 +1071,7 @@ export async function getTemplates({ return Promise.reject(data); } -export async function getTemplateById(viewId: string) { +export async function getTemplateById (viewId: string) { const url = `/api/template-center/template/${viewId}`; const response = await axiosInstance?.get<{ code: number; @@ -1082,7 +1088,7 @@ export async function getTemplateById(viewId: string) { return Promise.reject(data); } -export async function deleteTemplate(viewId: string) { +export async function deleteTemplate (viewId: string) { const url = `/api/template-center/template/${viewId}`; const response = await axiosInstance?.delete<{ code: number; @@ -1096,7 +1102,7 @@ export async function deleteTemplate(viewId: string) { return Promise.reject(response?.data.message); } -export async function getTemplateCategories() { +export async function getTemplateCategories () { const url = '/api/template-center/category'; const response = await axiosInstance?.get<{ code: number; @@ -1116,7 +1122,7 @@ export async function getTemplateCategories() { return Promise.reject(data); } -export async function addTemplateCategory(category: TemplateCategoryFormValues) { +export async function addTemplateCategory (category: TemplateCategoryFormValues) { const url = '/api/template-center/category'; const response = await axiosInstance?.post<{ code: number; @@ -1130,7 +1136,7 @@ export async function addTemplateCategory(category: TemplateCategoryFormValues) return Promise.reject(response?.data.message); } -export async function updateTemplateCategory(id: string, category: TemplateCategoryFormValues) { +export async function updateTemplateCategory (id: string, category: TemplateCategoryFormValues) { const url = `/api/template-center/category/${id}`; const response = await axiosInstance?.put<{ code: number; @@ -1144,7 +1150,7 @@ export async function updateTemplateCategory(id: string, category: TemplateCateg return Promise.reject(response?.data.message); } -export async function deleteTemplateCategory(categoryId: string) { +export async function deleteTemplateCategory (categoryId: string) { const url = `/api/template-center/category/${categoryId}`; const response = await axiosInstance?.delete<{ code: number; @@ -1158,7 +1164,7 @@ export async function deleteTemplateCategory(categoryId: string) { return Promise.reject(response?.data.message); } -export async function getTemplateCreators() { +export async function getTemplateCreators () { const url = '/api/template-center/creator'; const response = await axiosInstance?.get<{ code: number; @@ -1177,7 +1183,7 @@ export async function getTemplateCreators() { return Promise.reject(data); } -export async function createTemplateCreator(creator: TemplateCreatorFormValues) { +export async function createTemplateCreator (creator: TemplateCreatorFormValues) { const url = '/api/template-center/creator'; const response = await axiosInstance?.post<{ code: number; @@ -1191,7 +1197,7 @@ export async function createTemplateCreator(creator: TemplateCreatorFormValues) return Promise.reject(response?.data.message); } -export async function updateTemplateCreator(creatorId: string, creator: TemplateCreatorFormValues) { +export async function updateTemplateCreator (creatorId: string, creator: TemplateCreatorFormValues) { const url = `/api/template-center/creator/${creatorId}`; const response = await axiosInstance?.put<{ code: number; @@ -1205,7 +1211,7 @@ export async function updateTemplateCreator(creatorId: string, creator: Template return Promise.reject(response?.data.message); } -export async function deleteTemplateCreator(creatorId: string) { +export async function deleteTemplateCreator (creatorId: string) { const url = `/api/template-center/creator/${creatorId}`; const response = await axiosInstance?.delete<{ code: number; @@ -1219,7 +1225,7 @@ export async function deleteTemplateCreator(creatorId: string) { return Promise.reject(response?.data.message); } -export async function uploadTemplateAvatar(file: File) { +export async function uploadTemplateAvatar (file: File) { const url = '/api/template-center/avatar'; const formData = new FormData(); @@ -1249,7 +1255,7 @@ export async function uploadTemplateAvatar(file: File) { return Promise.reject(data); } -export async function getInvitation(invitationId: string) { +export async function getInvitation (invitationId: string) { const url = `/api/workspace/invite/${invitationId}`; const response = await axiosInstance?.get<{ code: number; @@ -1266,7 +1272,7 @@ export async function getInvitation(invitationId: string) { return Promise.reject(data); } -export async function acceptInvitation(invitationId: string) { +export async function acceptInvitation (invitationId: string) { const url = `/api/workspace/accept-invite/${invitationId}`; const response = await axiosInstance?.post<{ code: number; @@ -1280,7 +1286,7 @@ export async function acceptInvitation(invitationId: string) { return Promise.reject(response?.data.message); } -export async function getRequestAccessInfo(requestId: string): Promise { +export async function getRequestAccessInfo (requestId: string): Promise { const url = `/api/access-request/${requestId}`; const response = await axiosInstance?.get<{ code: number; @@ -1310,7 +1316,7 @@ export async function getRequestAccessInfo(requestId: string): Promise void) { +export async function uploadImportFile (presignedUrl: string, file: File, onProgress: (progress: number) => void) { const response = await axios.put(presignedUrl, file, { onUploadProgress: (progressEvent) => { const { progress = 0 } = progressEvent; @@ -1460,7 +1466,31 @@ export async function uploadImportFile(presignedUrl: string, file: File, onProgr }); } -export async function addAppPage(workspaceId: string, parentViewId: string, { +export async function createFolderView (workspaceId: string, payload: CreateFolderViewPayload) { + const url = `/api/workspace/${workspaceId}/folder-view`; + + const res = await axiosInstance?.post<{ + code: number; + data: { + view_id: string; + }; + message: string; + }>(url, { + parent_view_id: payload.parentViewId, + layout: payload.layout, + name: payload.name, + view_id: payload.viewId, + database_id: payload.databaseId, + }); + + if (res?.data.code === 0) { + return res.data.data.view_id; + } + + return Promise.reject(res?.data); +} + +export async function addAppPage (workspaceId: string, parentViewId: string, { layout, name, }: CreatePagePayload) { @@ -1484,7 +1514,7 @@ export async function addAppPage(workspaceId: string, parentViewId: string, { return Promise.reject(response?.data); } -export async function updatePage(workspaceId: string, viewId: string, data: UpdatePagePayload) { +export async function updatePage (workspaceId: string, viewId: string, data: UpdatePagePayload) { const url = `/api/workspace/${workspaceId}/page-view/${viewId}`; const res = await axiosInstance?.patch<{ @@ -1499,7 +1529,7 @@ export async function updatePage(workspaceId: string, viewId: string, data: Upda return Promise.reject(res?.data); } -export async function deleteTrash(workspaceId: string, viewId?: string) { +export async function deleteTrash (workspaceId: string, viewId?: string) { if (viewId) { const url = `/api/workspace/${workspaceId}/trash/${viewId}`; const response = await axiosInstance?.delete<{ @@ -1528,7 +1558,7 @@ export async function deleteTrash(workspaceId: string, viewId?: string) { } -export async function moveToTrash(workspaceId: string, viewId: string) { +export async function moveToTrash (workspaceId: string, viewId: string) { const url = `/api/workspace/${workspaceId}/page-view/${viewId}/move-to-trash`; const response = await axiosInstance?.post<{ code: number; @@ -1542,7 +1572,7 @@ export async function moveToTrash(workspaceId: string, viewId: string) { return Promise.reject(response?.data); } -export async function movePageTo(workspaceId: string, viewId: string, parentViewId: string, prevViewId?: string) { +export async function movePageTo (workspaceId: string, viewId: string, parentViewId: string, prevViewId?: string) { const url = `/api/workspace/${workspaceId}/page-view/${viewId}/move`; const response = await axiosInstance?.post<{ code: number; @@ -1559,7 +1589,7 @@ export async function movePageTo(workspaceId: string, viewId: string, parentView return Promise.reject(response?.data); } -export async function restorePage(workspaceId: string, viewId?: string) { +export async function restorePage (workspaceId: string, viewId?: string) { const url = viewId ? `/api/workspace/${workspaceId}/page-view/${viewId}/restore-from-trash` : `/api/workspace/${workspaceId}/restore-all-pages-from-trash`; const response = await axiosInstance?.post<{ code: number; @@ -1573,7 +1603,7 @@ export async function restorePage(workspaceId: string, viewId?: string) { return Promise.reject(response?.data); } -export async function createSpace(workspaceId: string, payload: CreateSpacePayload) { +export async function createSpace (workspaceId: string, payload: CreateSpacePayload) { const url = `/api/workspace/${workspaceId}/space`; const response = await axiosInstance?.post<{ code: number; @@ -1590,7 +1620,7 @@ export async function createSpace(workspaceId: string, payload: CreateSpacePaylo return Promise.reject(response?.data); } -export async function updateSpace(workspaceId: string, payload: UpdateSpacePayload) { +export async function updateSpace (workspaceId: string, payload: UpdateSpacePayload) { const url = `/api/workspace/${workspaceId}/space/${payload.view_id}`; const data = omit(payload, ['view_id']); const response = await axiosInstance?.patch<{ @@ -1605,7 +1635,7 @@ export async function updateSpace(workspaceId: string, payload: UpdateSpacePaylo return Promise.reject(response?.data); } -export async function uploadFile(workspaceId: string, viewId: string, file: File, onProgress?: (progress: number) => void) { +export async function uploadFile (workspaceId: string, viewId: string, file: File, onProgress?: (progress: number) => void) { const url = `/api/file_storage/${workspaceId}/v1/blob/${viewId}`; // Check file size, if over 7MB, check subscription plan @@ -1666,7 +1696,7 @@ export async function uploadFile(workspaceId: string, viewId: string, file: File } -export async function inviteMembers(workspaceId: string, emails: string[]) { +export async function inviteMembers (workspaceId: string, emails: string[]) { const url = `/api/workspace/${workspaceId}/invite`; const payload = emails.map(e => ({ @@ -1686,7 +1716,7 @@ export async function inviteMembers(workspaceId: string, emails: string[]) { return Promise.reject(res?.data); } -export async function getMembers(workspaceId: string) { +export async function getMembers (workspaceId: string) { const url = `/api/workspace/${workspaceId}/member`; const res = await axiosInstance?.get<{ code: number; @@ -1701,7 +1731,7 @@ export async function getMembers(workspaceId: string) { return Promise.reject(res?.data); } -export async function leaveWorkspace(workspaceId: string) { +export async function leaveWorkspace (workspaceId: string) { const url = `/api/workspace/${workspaceId}/leave`; const res = await axiosInstance?.post<{ code: number; @@ -1715,7 +1745,7 @@ export async function leaveWorkspace(workspaceId: string) { return Promise.reject(res?.data); } -export async function deleteWorkspace(workspaceId: string) { +export async function deleteWorkspace (workspaceId: string) { const url = `/api/workspace/${workspaceId}`; const res = await axiosInstance?.delete<{ code: number; @@ -1729,7 +1759,7 @@ export async function deleteWorkspace(workspaceId: string) { return Promise.reject(res?.data); } -export async function getQuickNoteList(workspaceId: string, params: { +export async function getQuickNoteList (workspaceId: string, params: { offset?: number; limit?: number; searchTerm?: string; @@ -1760,7 +1790,7 @@ export async function getQuickNoteList(workspaceId: string, params: { return Promise.reject(res?.data); } -export async function createQuickNote(workspaceId: string, payload: QuickNoteEditorData[]): Promise { +export async function createQuickNote (workspaceId: string, payload: QuickNoteEditorData[]): Promise { const url = `/api/workspace/${workspaceId}/quick-note`; const res = await axiosInstance?.post<{ code: number; @@ -1777,7 +1807,7 @@ export async function createQuickNote(workspaceId: string, payload: QuickNoteEdi return Promise.reject(res?.data); } -export async function updateQuickNote(workspaceId: string, noteId: string, payload: QuickNoteEditorData[]) { +export async function updateQuickNote (workspaceId: string, noteId: string, payload: QuickNoteEditorData[]) { const url = `/api/workspace/${workspaceId}/quick-note/${noteId}`; const res = await axiosInstance?.put<{ code: number; @@ -1793,7 +1823,7 @@ export async function updateQuickNote(workspaceId: string, noteId: string, paylo return Promise.reject(res?.data); } -export async function deleteQuickNote(workspaceId: string, noteId: string) { +export async function deleteQuickNote (workspaceId: string, noteId: string) { const url = `/api/workspace/${workspaceId}/quick-note/${noteId}`; const res = await axiosInstance?.delete<{ code: number; @@ -1807,7 +1837,7 @@ export async function deleteQuickNote(workspaceId: string, noteId: string) { return Promise.reject(res?.data); } -export async function cancelSubscription(workspaceId: string, plan: SubscriptionPlan, reason?: string) { +export async function cancelSubscription (workspaceId: string, plan: SubscriptionPlan, reason?: string) { const url = `/billing/api/v1/cancel-subscription`; const res = await axiosInstance?.post<{ code: number; @@ -1826,7 +1856,7 @@ export async function cancelSubscription(workspaceId: string, plan: Subscription return Promise.reject(res?.data); } -export async function searchWorkspace(workspaceId: string, query: string) { +export async function searchWorkspace (workspaceId: string, query: string) { const url = `/api/search/${workspaceId}`; const res = await axiosInstance?.get<{ code: number; @@ -1847,7 +1877,7 @@ export async function searchWorkspace(workspaceId: string, query: string) { return Promise.reject(res?.data); } -export async function getChatMessages(workspaceId: string, chatId: string, limit?: number | undefined) { +export async function getChatMessages (workspaceId: string, chatId: string, limit?: number | undefined) { const url = `/api/chat/${workspaceId}/${chatId}/message`; const response = await axiosInstance?.get<{ @@ -1867,7 +1897,7 @@ export async function getChatMessages(workspaceId: string, chatId: string, limit return Promise.reject(data); } -export async function duplicatePage(workspaceId: string, viewId: string) { +export async function duplicatePage (workspaceId: string, viewId: string) { const url = `/api/workspace/${workspaceId}/page-view/${viewId}/duplicate`; const response = await axiosInstance?.post<{ code: number; @@ -1881,7 +1911,7 @@ export async function duplicatePage(workspaceId: string, viewId: string) { return Promise.reject(response?.data); } -export async function joinWorkspaceByInvitationCode( +export async function joinWorkspaceByInvitationCode ( code: string, ) { const url = `/api/workspace/join-by-invite-code`; @@ -1902,7 +1932,7 @@ export async function joinWorkspaceByInvitationCode( return Promise.reject(response?.data); } -export async function getWorkspaceInfoByInvitationCode(code: string) { +export async function getWorkspaceInfoByInvitationCode (code: string) { const url = `/api/invite-code-info`; const response = await axiosInstance?.get<{ @@ -1930,4 +1960,65 @@ export async function getWorkspaceInfoByInvitationCode(code: string) { } return Promise.reject(data); +} + +export async function generateAISummaryForRow (workspaceId: string, payload: GenerateAISummaryRowPayload) { + const url = `/api/ai/${workspaceId}/summarize_row`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + data: { + text: string; + } + }>(url, { + workspace_id: workspaceId, + data: payload, + }); + + if (response?.data.code === 0) { + return response?.data.data.text; + } + + return Promise.reject(response?.data); +} + +export async function generateAITranslateForRow (workspaceId: string, payload: GenerateAITranslateRowPayload) { + const url = `/api/ai/${workspaceId}/translate_row`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + data: { + items: { + [key: string]: string; + }[] + } + }>(url, { + workspace_id: workspaceId, + data: payload, + }); + + if (response?.data.code === 0) { + return response?.data.data.items.map((item) => { + return Object.entries(item).map(([key, value]) => { + if (!value) return ''; + return `${key}: ${value}`; + }).join(', '); + }).join('\n'); + } + + return Promise.reject(response?.data); +} + +export async function createOrphanedView (workspaceId: string, payload: { document_id: string }) { + const url = `/api/workspace/${workspaceId}/orphaned-view`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, payload); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); } \ No newline at end of file diff --git a/src/application/services/js-services/index.ts b/src/application/services/js-services/index.ts index 463ef063..34751562 100644 --- a/src/application/services/js-services/index.ts +++ b/src/application/services/js-services/index.ts @@ -29,11 +29,12 @@ import { UploadTemplatePayload, } from '@/application/template.type'; import { + CreateFolderViewPayload, CreatePagePayload, CreateSpacePayload, CreateWorkspacePayload, DatabaseRelations, - DuplicatePublishView, + DuplicatePublishView, GenerateAISummaryRowPayload, GenerateAITranslateRowPayload, PublishViewPayload, QuickNoteEditorData, SubscriptionInterval, @@ -69,19 +70,19 @@ export class AFClientService implements AFService { } > = new Map(); - constructor(config: AFServiceConfig) { + constructor (config: AFServiceConfig) { APIService.initAPIService(config.cloudConfig); } - getAxiosInstance() { + getAxiosInstance () { return APIService.getAxiosInstance(); } - getClientId() { + getClientId () { return this.clientId; } - async publishView(workspaceId: string, viewId: string, payload?: PublishViewPayload) { + async publishView (workspaceId: string, viewId: string, payload?: PublishViewPayload) { if (this.publishViewInfo.has(viewId)) { this.publishViewInfo.delete(viewId); } @@ -89,7 +90,7 @@ export class AFClientService implements AFService { return APIService.publishView(workspaceId, viewId, payload); } - async unpublishView(workspaceId: string, viewId: string) { + async unpublishView (workspaceId: string, viewId: string) { if (this.publishViewInfo.has(viewId)) { this.publishViewInfo.delete(viewId); } @@ -97,28 +98,28 @@ export class AFClientService implements AFService { return APIService.unpublishView(workspaceId, viewId); } - async updatePublishNamespace(workspaceId: string, payload: UploadPublishNamespacePayload) { + async updatePublishNamespace (workspaceId: string, payload: UploadPublishNamespacePayload) { this.publishViewInfo.clear(); return APIService.updatePublishNamespace(workspaceId, payload); } - async getPublishNamespace(workspaceId: string) { + async getPublishNamespace (workspaceId: string) { return APIService.getPublishNamespace(workspaceId); } - async getPublishHomepage(workspaceId: string) { + async getPublishHomepage (workspaceId: string) { return APIService.getPublishHomepage(workspaceId); } - async updatePublishHomepage(workspaceId: string, viewId: string) { + async updatePublishHomepage (workspaceId: string, viewId: string) { return APIService.updatePublishHomepage(workspaceId, viewId); } - async removePublishHomepage(workspaceId: string) { + async removePublishHomepage (workspaceId: string) { return APIService.removePublishHomepage(workspaceId); } - async getPublishViewMeta(namespace: string, publishName: string) { + async getPublishViewMeta (namespace: string, publishName: string) { const name = `${namespace}_${publishName}`; const isLoaded = this.publishViewLoaded.has(name); @@ -140,7 +141,7 @@ export class AFClientService implements AFService { return viewMeta; } - async getPublishView(namespace: string, publishName: string) { + async getPublishView (namespace: string, publishName: string) { const name = `${namespace}_${publishName}`; const isLoaded = this.publishViewLoaded.has(name); @@ -175,7 +176,7 @@ export class AFClientService implements AFService { return doc; } - async getPublishRowDocument(viewId: string) { + async getPublishRowDocument (viewId: string) { const doc = await openCollabDB(viewId); if (hasCollabCache(doc)) { @@ -186,16 +187,15 @@ export class AFClientService implements AFService { } - async createRowDoc(rowKey: string) { + async createRowDoc (rowKey: string) { return createRowDoc(rowKey); } - deleteRowDoc(rowKey: string) { + deleteRowDoc (rowKey: string) { return deleteRowDoc(rowKey); } - async getAppDatabaseViewRelations(workspaceId: string, databaseStorageId: string) { - + async getAppDatabaseViewRelations (workspaceId: string, databaseStorageId: string) { const res = await APIService.getCollab(workspaceId, databaseStorageId, Types.WorkspaceDatabase); const doc = new Y.Doc(); @@ -213,7 +213,7 @@ export class AFClientService implements AFService { return result; } - async getPublishInfo(viewId: string) { + async getPublishInfo (viewId: string) { if (this.publishViewInfo.has(viewId)) { return this.publishViewInfo.get(viewId) as { namespace: string; @@ -249,36 +249,43 @@ export class AFClientService implements AFService { return data; } - async updatePublishConfig(workspaceId: string, config: UpdatePublishConfigPayload) { + async updatePublishConfig (workspaceId: string, config: UpdatePublishConfigPayload) { this.publishViewInfo.delete(config.view_id); return APIService.updatePublishConfig(workspaceId, config); } - async getPublishOutline(namespace: string) { + async getPublishOutline (namespace: string) { return APIService.getPublishOutline(namespace); } - async getAppOutline(workspaceId: string) { + async getAppOutline (workspaceId: string) { return APIService.getAppOutline(workspaceId); } - async getAppView(workspaceId: string, viewId: string) { + async getAppView (workspaceId: string, viewId: string) { return APIService.getView(workspaceId, viewId); } - async getAppFavorites(workspaceId: string) { + async createOrphanedView ( + workspaceId: string, + payload: { document_id: string }, + ) { + return APIService.createOrphanedView(workspaceId, payload); + } + + async getAppFavorites (workspaceId: string) { return APIService.getAppFavorites(workspaceId); } - async getAppRecent(workspaceId: string) { + async getAppRecent (workspaceId: string) { return APIService.getAppRecent(workspaceId); } - async getAppTrash(workspaceId: string) { + async getAppTrash (workspaceId: string) { return APIService.getAppTrash(workspaceId); } - async loginAuth(url: string) { + async loginAuth (url: string) { try { await APIService.signInWithUrl(url); emit(EventType.SESSION_VALID); @@ -291,48 +298,48 @@ export class AFClientService implements AFService { } @withSignIn() - async signInMagicLink({ email }: { email: string; redirectTo: string }) { + async signInMagicLink ({ email }: { email: string; redirectTo: string }) { return await APIService.signInWithMagicLink(email, AUTH_CALLBACK_URL); } @withSignIn() - async signInOTP(params: { email: string; code: string; redirectTo: string }) { + async signInOTP (params: { email: string; code: string; redirectTo: string }) { return APIService.signInOTP(params); } @withSignIn() - async signInGoogle(_: { redirectTo: string }) { + async signInGoogle (_: { redirectTo: string }) { return APIService.signInGoogle(AUTH_CALLBACK_URL); } @withSignIn() - async signInApple(_: { redirectTo: string }) { + async signInApple (_: { redirectTo: string }) { return APIService.signInApple(AUTH_CALLBACK_URL); } @withSignIn() - async signInGithub(_: { redirectTo: string }) { + async signInGithub (_: { redirectTo: string }) { return APIService.signInGithub(AUTH_CALLBACK_URL); } @withSignIn() - async signInDiscord(_: { redirectTo: string }) { + async signInDiscord (_: { redirectTo: string }) { return APIService.signInDiscord(AUTH_CALLBACK_URL); } - async getWorkspaces() { + async getWorkspaces () { const data = APIService.getWorkspaces(); return data; } - async getWorkspaceFolder(workspaceId: string) { + async getWorkspaceFolder (workspaceId: string) { const data = await APIService.getWorkspaceFolder(workspaceId); return data; } - async getCurrentUser() { + async getCurrentUser () { const token = getTokenParsed(); const userId = token?.user?.id; @@ -349,19 +356,19 @@ export class AFClientService implements AFService { return user; } - async openWorkspace(workspaceId: string) { + async openWorkspace (workspaceId: string) { return APIService.openWorkspace(workspaceId); } - async createWorkspace(payload: CreateWorkspacePayload) { + async createWorkspace (payload: CreateWorkspacePayload) { return APIService.createWorkspace(payload); } - async updateWorkspace(workspaceId: string, payload: UpdateWorkspacePayload) { + async updateWorkspace (workspaceId: string, payload: UpdateWorkspacePayload) { return APIService.updateWorkspace(workspaceId, payload); } - async getUserWorkspaceInfo() { + async getUserWorkspaceInfo () { const workspaceInfo = await APIService.getUserWorkspaceInfo(); if (!workspaceInfo) { @@ -375,7 +382,7 @@ export class AFClientService implements AFService { }; } - async duplicatePublishView(params: DuplicatePublishView) { + async duplicatePublishView (params: DuplicatePublishView) { return APIService.duplicatePublishView(params.workspaceId, { dest_view_id: params.spaceViewId, published_view_id: params.viewId, @@ -383,90 +390,90 @@ export class AFClientService implements AFService { }); } - createCommentOnPublishView(viewId: string, content: string, replyCommentId: string | undefined): Promise { + createCommentOnPublishView (viewId: string, content: string, replyCommentId: string | undefined): Promise { return APIService.createGlobalCommentOnPublishView(viewId, content, replyCommentId); } - deleteCommentOnPublishView(viewId: string, commentId: string): Promise { + deleteCommentOnPublishView (viewId: string, commentId: string): Promise { return APIService.deleteGlobalCommentOnPublishView(viewId, commentId); } - getPublishViewGlobalComments(viewId: string): Promise { + getPublishViewGlobalComments (viewId: string): Promise { return APIService.getPublishViewComments(viewId); } - getPublishViewReactions(viewId: string, commentId?: string): Promise> { + getPublishViewReactions (viewId: string, commentId?: string): Promise> { return APIService.getReactions(viewId, commentId); } - addPublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise { + addPublishViewReaction (viewId: string, commentId: string, reactionType: string): Promise { return APIService.addReaction(viewId, commentId, reactionType); } - removePublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise { + removePublishViewReaction (viewId: string, commentId: string, reactionType: string): Promise { return APIService.removeReaction(viewId, commentId, reactionType); } - async getTemplateCategories() { + async getTemplateCategories () { return APIService.getTemplateCategories(); } - async getTemplateCreators() { + async getTemplateCreators () { return APIService.getTemplateCreators(); } - async createTemplate(template: UploadTemplatePayload) { + async createTemplate (template: UploadTemplatePayload) { return APIService.createTemplate(template); } - async updateTemplate(id: string, template: UploadTemplatePayload) { + async updateTemplate (id: string, template: UploadTemplatePayload) { return APIService.updateTemplate(id, template); } - async getTemplateById(id: string) { + async getTemplateById (id: string) { return APIService.getTemplateById(id); } - async getTemplates(params: { + async getTemplates (params: { categoryId?: string; nameContains?: string; }) { return APIService.getTemplates(params); } - async deleteTemplate(id: string) { + async deleteTemplate (id: string) { return APIService.deleteTemplate(id); } - async addTemplateCategory(category: TemplateCategoryFormValues) { + async addTemplateCategory (category: TemplateCategoryFormValues) { return APIService.addTemplateCategory(category); } - async updateTemplateCategory(categoryId: string, category: TemplateCategoryFormValues) { + async updateTemplateCategory (categoryId: string, category: TemplateCategoryFormValues) { return APIService.updateTemplateCategory(categoryId, category); } - async deleteTemplateCategory(categoryId: string) { + async deleteTemplateCategory (categoryId: string) { return APIService.deleteTemplateCategory(categoryId); } - async updateTemplateCreator(creatorId: string, creator: TemplateCreatorFormValues) { + async updateTemplateCreator (creatorId: string, creator: TemplateCreatorFormValues) { return APIService.updateTemplateCreator(creatorId, creator); } - async createTemplateCreator(creator: TemplateCreatorFormValues) { + async createTemplateCreator (creator: TemplateCreatorFormValues) { return APIService.createTemplateCreator(creator); } - async deleteTemplateCreator(creatorId: string) { + async deleteTemplateCreator (creatorId: string) { return APIService.deleteTemplateCreator(creatorId); } - async uploadTemplateAvatar(file: File) { + async uploadTemplateAvatar (file: File) { return APIService.uploadTemplateAvatar(file); } - async getPageDoc(workspaceId: string, viewId: string, errorCallback?: (error: { + async getPageDoc (workspaceId: string, viewId: string, errorCallback?: (error: { code: number; }) => void) { @@ -509,49 +516,51 @@ export class AFClientService implements AFService { return doc; } - async getInvitation(invitationId: string) { + async getInvitation (invitationId: string) { return APIService.getInvitation(invitationId); } - async acceptInvitation(invitationId: string) { + async acceptInvitation (invitationId: string) { return APIService.acceptInvitation(invitationId); } - approveRequestAccess(requestId: string): Promise { + approveRequestAccess (requestId: string): Promise { return APIService.approveRequestAccess(requestId); } - getRequestAccessInfo(requestId: string) { + getRequestAccessInfo (requestId: string) { return APIService.getRequestAccessInfo(requestId); } - sendRequestAccess(workspaceId: string, viewId: string): Promise { + sendRequestAccess (workspaceId: string, viewId: string): Promise { return APIService.sendRequestAccess(workspaceId, viewId); } - getSubscriptionLink(workspaceId: string, plan: SubscriptionPlan, interval: SubscriptionInterval) { + getSubscriptionLink (workspaceId: string, plan: SubscriptionPlan, interval: SubscriptionInterval) { return APIService.getSubscriptionLink(workspaceId, plan, interval); } - cancelSubscription(workspaceId: string, plan: SubscriptionPlan, reason?: string) { + cancelSubscription (workspaceId: string, plan: SubscriptionPlan, reason?: string) { return APIService.cancelSubscription(workspaceId, plan, reason); } - getSubscriptions() { + getSubscriptions () { return APIService.getSubscriptions(); } - getActiveSubscription(workspaceId: string) { + getActiveSubscription (workspaceId: string) { return APIService.getActiveSubscription(workspaceId); } - getWorkspaceSubscriptions(workspaceId: string) { + getWorkspaceSubscriptions (workspaceId: string) { return APIService.getWorkspaceSubscriptions(workspaceId); } - registerDocUpdate(doc: Y.Doc, context: { + private registeredDocSync = new Map(); + + registerDocUpdate = (doc: Y.Doc, context: { workspaceId: string, objectId: string, collabType: Types - }) { + }) => { const token = getTokenParsed(); const userId = token?.user.id; @@ -559,74 +568,83 @@ export class AFClientService implements AFService { throw new Error('User not found'); } + if (this.registeredDocSync.has(context.objectId)) { + return; + } + const sync = new SyncManager(doc, { userId, ...context }); + this.registeredDocSync.set(context.objectId, sync); sync.initialize(); - } + }; - async importFile(file: File, onProgress: (progress: number) => void) { + async importFile (file: File, onProgress: (progress: number) => void) { const task = await APIService.createImportTask(file); await APIService.uploadImportFile(task.presignedUrl, file, onProgress); } - async createSpace(workspaceId: string, payload: CreateSpacePayload) { + async createSpace (workspaceId: string, payload: CreateSpacePayload) { return APIService.createSpace(workspaceId, payload); } - async updateSpace(workspaceId: string, payload: UpdateSpacePayload) { + async updateSpace (workspaceId: string, payload: UpdateSpacePayload) { return APIService.updateSpace(workspaceId, payload); } - async addAppPage(workspaceId: string, parentViewId: string, payload: CreatePagePayload) { + async addAppPage (workspaceId: string, parentViewId: string, payload: CreatePagePayload) { return APIService.addAppPage(workspaceId, parentViewId, payload); } - async updateAppPage(workspaceId: string, viewId: string, data: UpdatePagePayload) { + async createFolderView (workspaceId: string, payload: CreateFolderViewPayload) { + return APIService.createFolderView(workspaceId, payload); + } + + async updateAppPage (workspaceId: string, viewId: string, data: UpdatePagePayload) { return APIService.updatePage(workspaceId, viewId, data); } - async duplicateAppPage(workspaceId: string, viewId: string) { + async duplicateAppPage (workspaceId: string, viewId: string) { return APIService.duplicatePage(workspaceId, viewId); } - async deleteTrash(workspaceId: string, viewId?: string) { + async deleteTrash (workspaceId: string, viewId?: string) { return APIService.deleteTrash(workspaceId, viewId); } - async moveToTrash(workspaceId: string, viewId: string) { + async moveToTrash (workspaceId: string, viewId: string) { return APIService.moveToTrash(workspaceId, viewId); } - async restoreFromTrash(workspaceId: string, viewId?: string) { + async restoreFromTrash (workspaceId: string, viewId?: string) { return APIService.restorePage(workspaceId, viewId); } - async movePage(workspaceId: string, viewId: string, parentId: string, prevViewId?: string) { + async movePage (workspaceId: string, viewId: string, parentId: string, prevViewId?: string) { return APIService.movePageTo(workspaceId, viewId, parentId, prevViewId); } - async uploadFile(workspaceId: string, viewId: string, file: File, onProgress?: (progress: number) => void) { + async uploadFile (workspaceId: string, viewId: string, file: File, onProgress?: (progress: number) => void) { return APIService.uploadFile(workspaceId, viewId, file, onProgress); } - deleteWorkspace(workspaceId: string): Promise { + deleteWorkspace (workspaceId: string): Promise { return APIService.deleteWorkspace(workspaceId); } - leaveWorkspace(workspaceId: string): Promise { + leaveWorkspace (workspaceId: string): Promise { return APIService.leaveWorkspace(workspaceId); } - inviteMembers(workspaceId: string, emails: string[]): Promise { + inviteMembers (workspaceId: string, emails: string[]): Promise { return APIService.inviteMembers(workspaceId, emails); } - getWorkspaceMembers(workspaceId: string): Promise { + getWorkspaceMembers (workspaceId: string): Promise { return APIService.getMembers(workspaceId); } - getQuickNoteList(workspaceId: string, params: { + getQuickNoteList (workspaceId: string, params: { offset?: number; limit?: number; searchTerm?: string; @@ -634,23 +652,23 @@ export class AFClientService implements AFService { return APIService.getQuickNoteList(workspaceId, params); } - createQuickNote(workspaceId: string, data: QuickNoteEditorData[]) { + createQuickNote (workspaceId: string, data: QuickNoteEditorData[]) { return APIService.createQuickNote(workspaceId, data); } - updateQuickNote(workspaceId: string, id: string, data: QuickNoteEditorData[]) { + updateQuickNote (workspaceId: string, id: string, data: QuickNoteEditorData[]) { return APIService.updateQuickNote(workspaceId, id, data); } - deleteQuickNote(workspaceId: string, id: string) { + deleteQuickNote (workspaceId: string, id: string) { return APIService.deleteQuickNote(workspaceId, id); } - searchWorkspace(workspaceId: string, query: string) { + searchWorkspace (workspaceId: string, query: string) { return APIService.searchWorkspace(workspaceId, query); } - async getChatMessages( + async getChatMessages ( workspaceId: string, chatId: string, limit?: number | undefined, @@ -658,11 +676,19 @@ export class AFClientService implements AFService { return APIService.getChatMessages(workspaceId, chatId, limit); } - async joinWorkspaceByInvitationCode(code: string) { + async joinWorkspaceByInvitationCode (code: string) { return APIService.joinWorkspaceByInvitationCode(code); } - async getWorkspaceInfoByInvitationCode(code: string) { + async getWorkspaceInfoByInvitationCode (code: string) { return APIService.getWorkspaceInfoByInvitationCode(code); } + + async generateAISummaryForRow (workspaceId: string, payload: GenerateAISummaryRowPayload) { + return APIService.generateAISummaryForRow(workspaceId, payload); + } + + async generateAITranslateForRow (workspaceId: string, payload: GenerateAITranslateRowPayload) { + return APIService.generateAITranslateForRow(workspaceId, payload); + } } diff --git a/src/application/services/js-services/sync.ts b/src/application/services/js-services/sync.ts index 64521a03..8d3a09db 100644 --- a/src/application/services/js-services/sync.ts +++ b/src/application/services/js-services/sync.ts @@ -17,7 +17,7 @@ export class SyncManager { private isSending = false; - constructor(private doc: Y.Doc, private context: { + constructor (private doc: Y.Doc, private context: { userId: string, workspaceId: string, objectId: string, collabType: Types }) { this.versionVector = this.loadVersionVector(); @@ -26,41 +26,41 @@ export class SyncManager { this.setupListener(); } - private setupListener() { + private setupListener () { this.doc.on('update', (_update: Uint8Array, origin: CollabOrigin) => { - if(origin === CollabOrigin.Remote) return; + if (origin === CollabOrigin.Remote) return; console.log('Local changes detected. Sending update...', origin); this.debouncedSendUpdate(); }); } - private getStorageKey(baseKey: string): string { + private getStorageKey (baseKey: string): string { return `${this.context.userId}_${baseKey}_${this.context.workspaceId}_${this.context.objectId}`; } - private loadVersionVector(): number { + private loadVersionVector (): number { const storedVector = localStorage.getItem(this.getStorageKey(VERSION_VECTOR_KEY)); return storedVector ? parseInt(storedVector, 10) : 0; } - private saveVersionVector() { + private saveVersionVector () { localStorage.setItem(this.getStorageKey(VERSION_VECTOR_KEY), this.versionVector.toString()); } - private loadUnsyncedFlag(): boolean { + private loadUnsyncedFlag (): boolean { return localStorage.getItem(this.getStorageKey(UNSYNCED_FLAG_KEY)) === 'true'; } - private saveUnsyncedFlag() { + private saveUnsyncedFlag () { localStorage.setItem(this.getStorageKey(UNSYNCED_FLAG_KEY), this.hasUnsyncedChanges.toString()); } - private loadLastSyncedAt(): string { + private loadLastSyncedAt (): string { return localStorage.getItem(this.getStorageKey(LAST_SYNCED_AT_KEY)) || ''; } - private saveLastSyncedAt() { + private saveLastSyncedAt () { localStorage.setItem(this.getStorageKey(LAST_SYNCED_AT_KEY), this.lastSyncedAt); } @@ -71,8 +71,8 @@ export class SyncManager { void this.sendUpdate(); }, 1000); // 1 second debounce - private async sendUpdate() { - if(this.isSending) return; + private async sendUpdate () { + if (this.isSending) return; this.isSending = true; try { @@ -85,14 +85,14 @@ export class SyncManager { const response = await updateCollab(this.context.workspaceId, this.context.objectId, this.context.collabType, update, context); - if(response) { + if (response) { console.log(`Update sent successfully. Server version: ${response.version_vector}`); // Update last synced time this.lastSyncedAt = String(Date.now()); this.saveLastSyncedAt(); - if(response.version_vector === this.versionVector) { + if (response.version_vector === this.versionVector) { // Our update was the latest this.hasUnsyncedChanges = false; this.saveUnsyncedFlag(); @@ -106,7 +106,7 @@ export class SyncManager { } else { return Promise.reject(response); } - } catch(error) { + } catch (error) { console.error('Failed to send update:', error); // Keep the unsynced flag as true this.hasUnsyncedChanges = true; @@ -116,23 +116,27 @@ export class SyncManager { } } - public initialize() { - if(this.hasUnsyncedChanges) { + public initialize () { + if (this.hasUnsyncedChanges) { console.log('Unsynced changes found. Sending update...'); // Send an update if there are unsynced changes this.debouncedSendUpdate(); } } - public getUnsyncedStatus(): boolean { + public getUnsyncedStatus (): boolean { return this.hasUnsyncedChanges; } - public getLastSyncedAt(): string { + public getLastSyncedAt (): string { return this.lastSyncedAt; } - public getCurrentVersionVector(): number { + public getCurrentVersionVector (): number { return this.versionVector; } + + public destroy () { + this.doc.off('update', this.setupListener); + } } \ No newline at end of file diff --git a/src/application/services/services.type.ts b/src/application/services/services.type.ts index 0fa4c1a3..7e425e32 100644 --- a/src/application/services/services.type.ts +++ b/src/application/services/services.type.ts @@ -24,7 +24,11 @@ import { CreateWorkspacePayload, UpdateWorkspacePayload, PublishViewPayload, - UploadPublishNamespacePayload, UpdatePublishConfigPayload, + UploadPublishNamespacePayload, + UpdatePublishConfigPayload, + CreateFolderViewPayload, + GenerateAISummaryRowPayload, + GenerateAITranslateRowPayload, } from '@/application/types'; import { GlobalComment, Reaction } from '@/application/comment.type'; import { ViewMeta } from '@/application/db/tables/view_metas'; @@ -112,6 +116,7 @@ export interface AppService { createSpace: (workspaceId: string, payload: CreateSpacePayload) => Promise; updateSpace: (workspaceId: string, payload: UpdateSpacePayload) => Promise; addAppPage: (workspaceId: string, parentViewId: string, payload: CreatePagePayload) => Promise; + createFolderView: (workspaceId: string, payload: CreateFolderViewPayload) => Promise; updateAppPage: (workspaceId: string, viewId: string, data: UpdatePagePayload) => Promise; deleteTrash: (workspaceId: string, viewId?: string) => Promise; moveToTrash: (workspaceId: string, viewId: string) => Promise; @@ -129,6 +134,9 @@ export interface AppService { is_member: boolean; member_count: number; }>; + generateAISummaryForRow: (workspaceId: string, payload: GenerateAISummaryRowPayload) => Promise; + generateAITranslateForRow: (workspaceId: string, payload: GenerateAITranslateRowPayload) => Promise; + createOrphanedView: (workspaceId: string, payload: { document_id: string }) => Promise; } export interface QuickNoteService { diff --git a/src/application/types.ts b/src/application/types.ts index 56fc515a..88f59d7d 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -354,6 +354,9 @@ export enum YjsDatabaseKey { show_weekends = 'show_weekends', layout_ty = 'layout_ty', icon = 'icon', + is_inline = 'is_inline', + auto_fill = 'auto_fill', + language = 'language', } export interface YDoc extends Y.Doc { @@ -364,7 +367,7 @@ export interface YDoc extends Y.Doc { export interface YDatabaseRow extends Y.Map { get (key: YjsDatabaseKey.id): RowId; - get (key: YjsDatabaseKey.height): string; + get (key: YjsDatabaseKey.database_id | YjsDatabaseKey.height): string; get (key: YjsDatabaseKey.visibility): boolean; @@ -390,7 +393,7 @@ export interface YDatabaseCell extends Y.Map { get (key: YjsDatabaseKey.field_type): string; - get (key: YjsDatabaseKey.data): object | string | boolean | number; + get (key: YjsDatabaseKey.data): string | boolean | number | null | Y.Array | object; get (key: YjsDatabaseKey.end_timestamp): EndTimestamp; @@ -410,6 +413,8 @@ export interface YSharedRoot extends Y.Map { get (key: YjsEditorKey.database): YDatabase; get (key: YjsEditorKey.database_row): YDatabaseRow; + + get (key: YjsEditorKey.meta): Y.Map; } export interface YFolder extends Y.Map { @@ -545,11 +550,13 @@ export interface YDatabaseView extends Y.Map { get (key: YjsDatabaseKey.row_orders): YDatabaseRowOrders; get (key: YjsDatabaseKey.calculations): YDatabaseCalculations; + + get (key: YjsDatabaseKey.is_inline): boolean; } -export type YDatabaseFieldOrders = Y.Array; // [ { id: FieldId } ] +export type YDatabaseFieldOrders = Y.Array<{ id: FieldId }>; // [ { id: FieldId } ] -export type YDatabaseRowOrders = Y.Array; // [ { id: RowId, height: number } ] +export type YDatabaseRowOrders = Y.Array<{ id: RowId, height: number }>; // [ { id: RowId, height: number } ] export type YDatabaseGroups = Y.Array; @@ -587,12 +594,12 @@ export interface YDatabaseGroup extends Y.Map { get (key: YjsDatabaseKey.field_id): FieldId; // eslint-disable-next-line @typescript-eslint/unified-signatures - get (key: YjsDatabaseKey.content): string; + get (key: YjsDatabaseKey.content): string; // "{"hide_empty":false,"condition":2}" get (key: YjsDatabaseKey.groups): YDatabaseGroupColumns; } -export type YDatabaseGroupColumns = Y.Array; +export type YDatabaseGroupColumns = Y.Array<{ id: string, visible: boolean }>; export interface YDatabaseGroupColumn extends Y.Map { get (key: YjsDatabaseKey.id): string; @@ -600,12 +607,6 @@ export interface YDatabaseGroupColumn extends Y.Map { get (key: YjsDatabaseKey.visible): boolean; } -export interface YDatabaseRowOrder extends Y.Map { - get (key: YjsDatabaseKey.id): SortId; - - get (key: YjsDatabaseKey.height): number; -} - export interface YDatabaseSort extends Y.Map { get (key: YjsDatabaseKey.id): SortId; @@ -675,21 +676,32 @@ export interface YDatabaseFieldTypeOption extends Y.Map { } export interface YMapFieldTypeOption extends Y.Map { + // single select, Multi select, File media get (key: YjsDatabaseKey.content): string; - // eslint-disable-next-line @typescript-eslint/unified-signatures - get (key: YjsDatabaseKey.data): string; - + // CreatedTime, LastEditedTime, DateTime // eslint-disable-next-line @typescript-eslint/unified-signatures get (key: YjsDatabaseKey.time_format): string; + // CreatedTime, LastEditedTime, DateTime // eslint-disable-next-line @typescript-eslint/unified-signatures get (key: YjsDatabaseKey.date_format): string; + // Relation get (key: YjsDatabaseKey.database_id): DatabaseId; + // Number // eslint-disable-next-line @typescript-eslint/unified-signatures get (key: YjsDatabaseKey.format): string; + + // LastModified and CreatedTime + get (key: YjsDatabaseKey.include_time): boolean; + + // AI Translate + // eslint-disable-next-line @typescript-eslint/unified-signatures + get (key: YjsDatabaseKey.auto_fill): boolean; + + get (key: YjsDatabaseKey.language): bigint; } export enum Types { @@ -876,6 +888,7 @@ export interface View { publisher_email?: string; publish_name?: string; publish_timestamp?: string; + parent_view_id?: string; } export interface UpdatePublishConfigPayload { @@ -1033,6 +1046,9 @@ export interface ViewComponentProps { onWordCountChange?: (viewId: string, props: TextCount) => void; uploadFile?: (file: File) => Promise; requestInstance?: AxiosInstance | null; + createFolderView?: (payload: CreateFolderViewPayload) => Promise; + generateAISummaryForRow?: (payload: GenerateAISummaryRowPayload) => Promise; + generateAITranslateForRow?: (payload: GenerateAITranslateRowPayload) => Promise; } export interface CreatePagePayload { @@ -1040,6 +1056,14 @@ export interface CreatePagePayload { name?: string; } +export interface CreateFolderViewPayload { + parentViewId: string, + layout: ViewLayout, + name?: string, + viewId?: string, + databaseId?: string, +} + export interface CreateSpacePayload { name?: string; space_icon?: string; @@ -1079,4 +1103,23 @@ export enum SettingMenuItem { WORKSPACE = 'WORKSPACE', MEMBERS = 'MEMBERS', SITES = 'SITES', -} \ No newline at end of file +} + +export interface GenerateAISummaryRowPayload { + Content: { + // key = field name, value = cell data + [key: string]: string; + }; +} + +export interface GenerateAITranslateRowPayload { + cells: { + // field name + title: string; + // cell data + content: string; + }[], + language: string, + include_header?: boolean +} + diff --git a/src/assets/icons/ai_summary.svg b/src/assets/icons/ai_summary.svg index 305a960f..6f295924 100644 --- a/src/assets/icons/ai_summary.svg +++ b/src/assets/icons/ai_summary.svg @@ -1,24 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + \ No newline at end of file diff --git a/src/assets/icons/ai_translate.svg b/src/assets/icons/ai_translate.svg index 330536e1..ce59ce79 100644 --- a/src/assets/icons/ai_translate.svg +++ b/src/assets/icons/ai_translate.svg @@ -1,17 +1,8 @@ - - - - - - - - - - - - - - + + + + + \ No newline at end of file diff --git a/src/assets/icons/arrow_left_line.svg b/src/assets/icons/arrow_left_line.svg new file mode 100644 index 00000000..ad55a3f4 --- /dev/null +++ b/src/assets/icons/arrow_left_line.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/assets/icons/board.svg b/src/assets/icons/board.svg index 88ae3e4b..8ba9a9bc 100644 --- a/src/assets/icons/board.svg +++ b/src/assets/icons/board.svg @@ -1,4 +1,6 @@ - - + + diff --git a/src/assets/icons/check_filled.svg b/src/assets/icons/check_filled.svg index 321f711d..a18cc5e9 100644 --- a/src/assets/icons/check_filled.svg +++ b/src/assets/icons/check_filled.svg @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/src/assets/icons/circle.svg b/src/assets/icons/circle.svg new file mode 100644 index 00000000..066cd705 --- /dev/null +++ b/src/assets/icons/circle.svg @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/circle_remove.svg b/src/assets/icons/circle_remove.svg new file mode 100644 index 00000000..0d1b4163 --- /dev/null +++ b/src/assets/icons/circle_remove.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/assets/icons/controller.svg b/src/assets/icons/controller.svg index bb3713be..a19e695f 100644 --- a/src/assets/icons/controller.svg +++ b/src/assets/icons/controller.svg @@ -1,3 +1,4 @@ - + diff --git a/src/assets/icons/created_at.svg b/src/assets/icons/created_at.svg index 4897c265..86c25450 100644 --- a/src/assets/icons/created_at.svg +++ b/src/assets/icons/created_at.svg @@ -1,5 +1,4 @@ - - - + + \ No newline at end of file diff --git a/src/assets/icons/database/ai.svg b/src/assets/icons/database/ai.svg new file mode 100644 index 00000000..0fe0f37d --- /dev/null +++ b/src/assets/icons/database/ai.svg @@ -0,0 +1,5 @@ + + + + diff --git a/src/assets/icons/database/checkbox.svg b/src/assets/icons/database/checkbox.svg new file mode 100644 index 00000000..44481658 --- /dev/null +++ b/src/assets/icons/database/checkbox.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/database/checklist.svg b/src/assets/icons/database/checklist.svg new file mode 100644 index 00000000..83e7f5a7 --- /dev/null +++ b/src/assets/icons/database/checklist.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/database/multiple_select.svg b/src/assets/icons/database/multiple_select.svg new file mode 100644 index 00000000..a2272a54 --- /dev/null +++ b/src/assets/icons/database/multiple_select.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/database/number.svg b/src/assets/icons/database/number.svg new file mode 100644 index 00000000..cdee4e43 --- /dev/null +++ b/src/assets/icons/database/number.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/database/single_select.svg b/src/assets/icons/database/single_select.svg new file mode 100644 index 00000000..ea6b6e06 --- /dev/null +++ b/src/assets/icons/database/single_select.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/src/assets/icons/database/text.svg b/src/assets/icons/database/text.svg new file mode 100644 index 00000000..3c3fbb5a --- /dev/null +++ b/src/assets/icons/database/text.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/database/url.svg b/src/assets/icons/database/url.svg new file mode 100644 index 00000000..ad2ed489 --- /dev/null +++ b/src/assets/icons/database/url.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/date.svg b/src/assets/icons/date.svg index d868f54a..46274c01 100644 --- a/src/assets/icons/date.svg +++ b/src/assets/icons/date.svg @@ -1,9 +1,4 @@ - - - - - - - - + + \ No newline at end of file diff --git a/src/assets/icons/grid.svg b/src/assets/icons/grid.svg index 4f28e8a8..36cc2cec 100644 --- a/src/assets/icons/grid.svg +++ b/src/assets/icons/grid.svg @@ -1,3 +1,4 @@ - + diff --git a/src/assets/icons/last_modified.svg b/src/assets/icons/last_modified.svg index 293e945c..7570db0f 100644 --- a/src/assets/icons/last_modified.svg +++ b/src/assets/icons/last_modified.svg @@ -1,4 +1,4 @@ - - + + \ No newline at end of file diff --git a/src/assets/icons/relation.svg b/src/assets/icons/relation.svg index 48cbae8a..a358bdf4 100644 --- a/src/assets/icons/relation.svg +++ b/src/assets/icons/relation.svg @@ -1,7 +1,4 @@ - - - - - - \ No newline at end of file + + + diff --git a/src/assets/icons/single_select.svg b/src/assets/icons/single_select.svg deleted file mode 100644 index f733d9c5..00000000 --- a/src/assets/icons/single_select.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/src/assets/icons/uncheck.svg b/src/assets/icons/uncheck.svg index 473b4808..bab69aa5 100644 --- a/src/assets/icons/uncheck.svg +++ b/src/assets/icons/uncheck.svg @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/src/components/_shared/appflowy-power/AppFlowyPower.tsx b/src/components/_shared/appflowy-power/AppFlowyPower.tsx index d5052ed7..00007aa9 100644 --- a/src/components/_shared/appflowy-power/AppFlowyPower.tsx +++ b/src/components/_shared/appflowy-power/AppFlowyPower.tsx @@ -1,6 +1,5 @@ -import { Divider } from '@mui/material'; -import React from 'react'; import { ReactComponent as AppFlowyLogo } from '@/assets/icons/appflowy.svg'; +import { Divider } from '@mui/material'; function AppFlowyPower({ divider, width }: { divider?: boolean; width?: number }) { return ( @@ -9,7 +8,7 @@ function AppFlowyPower({ divider, width }: { divider?: boolean; width?: number } width, }} className={ - 'sticky bottom-[-0.5px] flex w-full transform-gpu flex-col items-center justify-center rounded-[16px] bg-bg-body' + 'sticky bottom-[-0.5px] flex w-full transform-gpu flex-col items-center justify-center rounded-[16px] bg-background-primary' } > {divider && } @@ -22,7 +21,7 @@ function AppFlowyPower({ divider, width }: { divider?: boolean; width?: number } width, }} className={ - 'flex w-full cursor-pointer items-center justify-center gap-2 py-4 text-sm text-text-title opacity-50' + 'flex w-full cursor-pointer items-center justify-center gap-2 py-4 text-sm text-text-primary opacity-50' } > Powered by diff --git a/src/components/_shared/breadcrumb/Breadcrumb.tsx b/src/components/_shared/breadcrumb/Breadcrumb.tsx index 63687745..1dbbe1dc 100644 --- a/src/components/_shared/breadcrumb/Breadcrumb.tsx +++ b/src/components/_shared/breadcrumb/Breadcrumb.tsx @@ -1,11 +1,11 @@ import { UIVariant, View } from '@/application/types'; +import { ReactComponent as RightIcon } from '@/assets/icons/alt_arrow_right.svg'; +import { ReactComponent as MoreIcon } from '@/assets/icons/more.svg'; import BreadcrumbItem from '@/components/_shared/breadcrumb/BreadcrumbItem'; import BreadcrumbMoreModal from '@/components/_shared/breadcrumb/BreadcrumbMoreModal'; import { getPlatform } from '@/utils/platform'; import { IconButton } from '@mui/material'; import React, { memo, useMemo } from 'react'; -import { ReactComponent as RightIcon } from '@/assets/icons/alt_arrow_right.svg'; -import { ReactComponent as MoreIcon } from '@/assets/icons/more.svg'; export interface BreadcrumbProps { crumbs: View[]; @@ -24,11 +24,11 @@ export function Breadcrumb({ crumbs, toView, variant }: BreadcrumbProps) { return ( <> -
+
-
+
{ setOpenMore(true); @@ -43,7 +43,7 @@ export function Breadcrumb({ crumbs, toView, variant }: BreadcrumbProps) { const key = `${crumb.view_id}-${index}`; return ( -
+
diff --git a/src/components/_shared/breadcrumb/BreadcrumbItem.tsx b/src/components/_shared/breadcrumb/BreadcrumbItem.tsx index ec81be60..ebc19b99 100644 --- a/src/components/_shared/breadcrumb/BreadcrumbItem.tsx +++ b/src/components/_shared/breadcrumb/BreadcrumbItem.tsx @@ -1,17 +1,22 @@ import { UIVariant, View } from '@/application/types'; -import PublishIcon from '@/components/_shared/view-icon/PublishIcon'; import { notify } from '@/components/_shared/notify'; +import PageIcon from '@/components/_shared/view-icon/PageIcon'; +import PublishIcon from '@/components/_shared/view-icon/PublishIcon'; import SpaceIcon from '@/components/_shared/view-icon/SpaceIcon'; import { Tooltip } from '@mui/material'; -import React, { useMemo } from 'react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import PageIcon from '@/components/_shared/view-icon/PageIcon'; -function BreadcrumbItem({ crumb, disableClick = false, toView, variant }: { +function BreadcrumbItem({ + crumb, + disableClick = false, + toView, + variant, +}: { crumb: View; disableClick?: boolean; toView?: (viewId: string) => Promise; - variant?: UIVariant + variant?: UIVariant; }) { const { view_id, name, extra, is_published } = crumb; @@ -20,9 +25,9 @@ function BreadcrumbItem({ crumb, disableClick = false, toView, variant }: { const className = useMemo(() => { const classList = ['flex', 'items-center', 'gap-1.5', 'text-sm', 'overflow-hidden', 'max-sm:text-base']; - if(!disableClick && !extra?.is_space) { - if((is_published && variant === 'publish') || variant === 'app') { - classList.push('cursor-pointer hover:text-text-title hover:underline'); + if (!disableClick && !extra?.is_space) { + if ((is_published && variant === 'publish') || variant === 'app') { + classList.push('cursor-pointer hover:text-text-primary hover:underline'); } else { classList.push('flex-1'); } @@ -34,12 +39,12 @@ function BreadcrumbItem({ crumb, disableClick = false, toView, variant }: { return (
{ - if(disableClick || extra?.is_space || (!is_published && variant === 'publish')) return; + onClick={async () => { + if (disableClick || extra?.is_space || (!is_published && variant === 'publish')) return; try { await toView?.(view_id); // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch(e: any) { + } catch (e: any) { notify.error(e.message); } }} @@ -52,20 +57,14 @@ function BreadcrumbItem({ crumb, disableClick = false, toView, variant }: { char={extra.space_icon ? undefined : name.slice(0, 1)} /> ) : ( - + )} {name || t('menuAppHeader.defaultNewPageName')} - +
); } diff --git a/src/components/_shared/breadcrumb/BreadcrumbMoreModal.tsx b/src/components/_shared/breadcrumb/BreadcrumbMoreModal.tsx index c3d54532..8c6eb955 100644 --- a/src/components/_shared/breadcrumb/BreadcrumbMoreModal.tsx +++ b/src/components/_shared/breadcrumb/BreadcrumbMoreModal.tsx @@ -1,10 +1,15 @@ import { View } from '@/application/types'; import BreadcrumbItem from '@/components/_shared/breadcrumb/BreadcrumbItem'; import { NormalModal } from '@/components/_shared/modal'; -import React, { useMemo } from 'react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -function BreadcrumbMoreModal ({ open, onClose, crumbs, toView }: { +function BreadcrumbMoreModal({ + open, + onClose, + crumbs, + toView, +}: { open: boolean; onClose: () => void; crumbs: View[]; @@ -12,9 +17,11 @@ function BreadcrumbMoreModal ({ open, onClose, crumbs, toView }: { }) { const { t } = useTranslation(); const title = useMemo(() => { - return
-
{t('breadcrumbs.label')}
-
; + return ( +
+
{t('breadcrumbs.label')}
+
+ ); }, [t]); return ( @@ -29,7 +36,7 @@ function BreadcrumbMoreModal ({ open, onClose, crumbs, toView }: { open={open} onClose={onClose} > -
+
{crumbs.map((crumb, index) => (
- {index !== 0 &&
{'-'}
} - + {index !== 0 &&
{'-'}
} +
))}
@@ -54,4 +60,4 @@ function BreadcrumbMoreModal ({ open, onClose, crumbs, toView }: { ); } -export default BreadcrumbMoreModal; \ No newline at end of file +export default BreadcrumbMoreModal; diff --git a/src/components/_shared/color-picker/ColorPicker.tsx b/src/components/_shared/color-picker/ColorPicker.tsx index f457c49f..8be0ea16 100644 --- a/src/components/_shared/color-picker/ColorPicker.tsx +++ b/src/components/_shared/color-picker/ColorPicker.tsx @@ -1,11 +1,11 @@ import { EditorMarkFormat } from '@/application/slate-yjs/types'; import KeyboardNavigation from '@/components/_shared/keyboard_navigation/KeyboardNavigation'; import { ColorEnum, renderColor } from '@/utils/color'; -import React, { useCallback, useRef, useMemo } from 'react'; import Typography from '@mui/material/Typography'; +import { useCallback, useMemo, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; import TitleOutlined from '@mui/icons-material/TitleOutlined'; +import { useTranslation } from 'react-i18next'; export interface ColorPickerProps { onChange?: (format: EditorMarkFormat.FontColor | EditorMarkFormat.BgColor, color: string) => void; @@ -13,7 +13,7 @@ export interface ColorPickerProps { disableFocus?: boolean; } -export function ColorPicker ({ onEscape, onChange, disableFocus }: ColorPickerProps) { +export function ColorPicker({ onEscape, onChange, disableFocus }: ColorPickerProps) { const { t } = useTranslation(); const ref = useRef(null); @@ -25,7 +25,7 @@ export function ColorPicker ({ onEscape, onChange, disableFocus }: ColorPickerPr onChange?.(formatKey, color); }, - [onChange], + [onChange] ); const renderColorItem = useCallback( @@ -43,15 +43,15 @@ export function ColorPicker ({ onEscape, onChange, disableFocus }: ColorPickerPr backgroundColor: backgroundColor ? renderColor(backgroundColor) : 'transparent', color: color === '' ? 'var(--text-title)' : renderColor(color), }} - className={'flex h-5 w-5 items-center justify-center rounded border border-line-divider'} + className={'flex h-5 w-5 items-center justify-center rounded border border-border-primary'} >
-
{name}
+
{name}
); }, - [handleColorChange], + [handleColorChange] ); const colors = useMemo(() => { @@ -59,10 +59,7 @@ export function ColorPicker ({ onEscape, onChange, disableFocus }: ColorPickerPr { key: 'font_color', content: ( - + {t('editor.textColor')} ), @@ -112,10 +109,7 @@ export function ColorPicker ({ onEscape, onChange, disableFocus }: ColorPickerPr { key: 'bg_color', content: ( - + {t('editor.backgroundColor')} ), @@ -166,10 +160,7 @@ export function ColorPicker ({ onEscape, onChange, disableFocus }: ColorPickerPr }, [renderColorItem, t]); return ( -
+
void, + hideRemove?: boolean, + onSelectIcon?: (icon: { ty: ViewIconType, value: string, color?: string, content?: string }) => void, + onUploadFile?: (file: File) => Promise, +} & React.ComponentProps & { + popoverContentProps?: React.ComponentProps +}) { + const { t } = useTranslation(); + const [tabValue, setTabValue] = useState(defaultActiveTab); + const [ + openState, + setOpen, + ] = useState(open ?? false); + + const handleOpenChange = (open: boolean) => { + setOpen(open); + onOpenChange?.(open); + }; + + useEffect(() => { + if (open !== undefined) { + setOpen(open); + onOpenChange?.(open); + } + }, [onOpenChange, open]); + + const handleClose = () => { + setOpen(false); + }; + + if (!enable) return <>{children}; + + return ( + + + {children} + + e.preventDefault()} + onOpenAutoFocus={e => e.preventDefault()} + onClick={e => { + e.stopPropagation(); + }} + {...popoverContentProps} + > + +
+ + {tabs.map((tab) => ( + + + {tab.charAt(0).toUpperCase() + tab.slice(1)} + + + ))} + + + {!hideRemove && ( + + )} +
+ + + { + onSelectIcon?.({ + ty: ViewIconType.Emoji, + value: emoji, + }); + handleClose(); + }} + /> + + + { + onSelectIcon?.({ + ty: ViewIconType.Icon, + ...icon, + }); + handleClose(); + }} + container={popoverContentProps?.container as HTMLDivElement} + /> + + + + +
+ { + onSelectIcon?.({ + ty: ViewIconType.URL, + value: url, + }); + handleClose(); + }} + uploadAction={onUploadFile} + /> +
+ +
+
+
+
+ ); +} + +export default CustomIconPopover; \ No newline at end of file diff --git a/src/components/_shared/cutsom-icon/index.ts b/src/components/_shared/cutsom-icon/index.ts new file mode 100644 index 00000000..ec87b43a --- /dev/null +++ b/src/components/_shared/cutsom-icon/index.ts @@ -0,0 +1 @@ +export * from './CustomIconPopover'; \ No newline at end of file diff --git a/src/components/_shared/emoji-picker/EmojiItem.tsx b/src/components/_shared/emoji-picker/EmojiItem.tsx new file mode 100644 index 00000000..11b106da --- /dev/null +++ b/src/components/_shared/emoji-picker/EmojiItem.tsx @@ -0,0 +1,35 @@ +import { Emoji } from '@/components/_shared/emoji-picker/EmojiPicker.hooks'; +import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +function EmojiItem ({ + emoji, + onClick, + isFlag, +}: { + isFlag: boolean; + emoji: Emoji; + onClick: () => void; +}) { + return ( + + + + + + + {emoji.name} + + + ); +} + +export default EmojiItem; \ No newline at end of file diff --git a/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts b/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts index 0dbc727a..328d491c 100644 --- a/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts +++ b/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts @@ -1,17 +1,15 @@ import { notify } from '@/components/_shared/notify'; import { loadEmojiData } from '@/utils/emoji'; import { EmojiMartData } from '@emoji-mart/data'; -import { PopoverProps } from '@mui/material/Popover'; -import { PopoverOrigin } from '@mui/material/Popover/Popover'; import chunk from 'lodash-es/chunk'; -import React, { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; export interface EmojiCategory { id: string; emojis: Emoji[]; } -interface Emoji { +export interface Emoji { id: string; name: string; native: string; @@ -110,31 +108,6 @@ export function useLoadEmojiData ({ onEmojiSelect }: { onEmojiSelect: (emoji: st }; } -export function useSelectSkinPopoverProps (): PopoverProps & { - onOpen: (event: React.MouseEvent) => void; - onClose: () => void; -} { - const [anchorEl, setAnchorEl] = useState(undefined); - const onOpen = useCallback((event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }, []); - const onClose = useCallback(() => { - setAnchorEl(undefined); - }, []); - const open = Boolean(anchorEl); - const anchorOrigin = { vertical: 'bottom', horizontal: 'center' } as PopoverOrigin; - const transformOrigin = { vertical: 'top', horizontal: 'center' } as PopoverOrigin; - - return { - anchorEl, - onOpen, - onClose, - open, - anchorOrigin, - transformOrigin, - }; -} - function filterSearchValue ( emoji: { name: string; diff --git a/src/components/_shared/emoji-picker/EmojiPicker.tsx b/src/components/_shared/emoji-picker/EmojiPicker.tsx index 76ec60ae..3f4ab20e 100644 --- a/src/components/_shared/emoji-picker/EmojiPicker.tsx +++ b/src/components/_shared/emoji-picker/EmojiPicker.tsx @@ -1,5 +1,4 @@ -import CircularProgress from '@mui/material/CircularProgress'; -import React from 'react'; +import { Progress } from '@/components/ui/progress'; import { useLoadEmojiData } from './EmojiPicker.hooks'; import EmojiPickerHeader from './EmojiPickerHeader'; @@ -8,13 +7,10 @@ import emptyImageSrc from '@/assets/images/empty.png'; interface Props { onEmojiSelect: (emoji: string) => void; - onEscape?: () => void; - defaultEmoji?: string; - hideRemove?: boolean; size?: [number, number]; } -export function EmojiPicker({ defaultEmoji, onEscape, size, ...props }: Props) { +export function EmojiPicker ({ size, ...props }: Props) { const { skin, onSkinChange, emojiCategories, setSearchValue, searchValue, onSelect, loading, isEmpty } = useLoadEmojiData(props); @@ -25,19 +21,18 @@ export function EmojiPicker({ defaultEmoji, onEscape, size, ...props }: Props) { height: size ? size[1] : undefined, }} tabIndex={0} - className={'emoji-picker flex h-[360px] max-h-[70vh] flex-col p-4 pt-2'} + className={'emoji-picker flex h-[360px] max-h-[70vh] flex-col gap-3 px-3'} > {loading ? (
- +
) : isEmpty ? ( ) : ( diff --git a/src/components/_shared/emoji-picker/EmojiPickerCategories.tsx b/src/components/_shared/emoji-picker/EmojiPickerCategories.tsx index c257fb09..2c9f56a4 100644 --- a/src/components/_shared/emoji-picker/EmojiPickerCategories.tsx +++ b/src/components/_shared/emoji-picker/EmojiPickerCategories.tsx @@ -1,369 +1,27 @@ -import { EMOJI_SIZE, PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const'; -import { AFScroller } from '@/components/_shared/scroller'; -import { getDistanceEdge, inView } from '@/utils/position'; -import { Tooltip } from '@mui/material'; -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; -import AutoSizer from 'react-virtualized-auto-sizer'; -import { FixedSizeList } from 'react-window'; +import { PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const'; +import EmojisVirtualizer from '@/components/_shared/emoji-picker/EmojisVirtualizer'; +import { useMemo } from 'react'; import { EmojiCategory, getRowsWithCategories } from './EmojiPicker.hooks'; function EmojiPickerCategories ({ emojiCategories, onEmojiSelect, - onEscape, - defaultEmoji, }: { emojiCategories: EmojiCategory[]; onEmojiSelect: (emoji: string) => void; - onEscape?: () => void; - defaultEmoji?: string; }) { - const scrollRef = useRef(null); - const { t } = useTranslation(); - const [selectCell, setSelectCell] = React.useState({ - row: 1, - column: 0, - }); const rows = useMemo(() => { return getRowsWithCategories(emojiCategories, PER_ROW_EMOJI_COUNT); }, [emojiCategories]); - const mouseY = useRef(null); - const mouseX = useRef(null); - - const ref = React.useRef(null); - - const getCategoryName = useCallback( - (id: string) => { - const i18nName: Record = { - frequent: t('emoji.categories.frequentlyUsed'), - people: t('emoji.categories.people'), - nature: t('emoji.categories.nature'), - foods: t('emoji.categories.food'), - activity: t('emoji.categories.activities'), - places: t('emoji.categories.places'), - objects: t('emoji.categories.objects'), - symbols: t('emoji.categories.symbols'), - flags: t('emoji.categories.flags'), - }; - - return i18nName[id]; - }, - [t], - ); - - useEffect(() => { - scrollRef.current?.scrollTo({ - top: 0, - }); - - setSelectCell({ - row: 1, - column: 0, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rows]); - - const renderRow = useCallback( - ({ index, style }: { index: number; style: React.CSSProperties }) => { - const item = rows[index]; - const tagName = getCategoryName(item.id); - const isFlags = item.category === 'flags'; - - return ( -
- {item.type === 'category' ? ( -
{tagName}
- ) : null} -
- {item.emojis?.map((emoji, columnIndex) => { - const isSelected = selectCell.row === index && selectCell.column === columnIndex; - - const isDefaultEmoji = defaultEmoji === emoji.native; - const classList = [ - 'flex cursor-pointer items-center justify-center rounded text-[20px] hover:bg-fill-list-hover', - ]; - - if (isSelected) { - classList.push('bg-fill-list-hover'); - } - - if (isDefaultEmoji) { - classList.push('bg-fill-list-active'); - } - - if (isFlags) { - classList.push('icon'); - } - - return ( - -
{ - onEmojiSelect(emoji.native); - }} - onMouseMove={(e) => { - mouseY.current = e.clientY; - mouseX.current = e.clientX; - }} - onMouseEnter={(e) => { - mouseX.current = e.clientX; - mouseY.current = e.clientY; - }} - className={classList.join(' ')} - > - {emoji.native} -
-
- ); - })} -
-
- ); - }, - [defaultEmoji, getCategoryName, onEmojiSelect, rows, selectCell], - ); - - const getNewColumnIndex = useCallback( - (rowIndex: number, columnIndex: number): number => { - const row = rows[rowIndex]; - const length = row.emojis?.length; - let newColumnIndex = columnIndex; - - if (length && length <= columnIndex) { - newColumnIndex = length - 1 || 0; - } - - return newColumnIndex; - }, - [rows], - ); - - const findNextRow = useCallback( - (rowIndex: number, columnIndex: number): { row: number; column: number } => { - const rowLength = rows.length; - let nextRowIndex = rowIndex + 1; - - if (nextRowIndex >= rowLength - 1) { - nextRowIndex = rowLength - 1; - } else if (rows[nextRowIndex].type === 'category') { - nextRowIndex = findNextRow(nextRowIndex, columnIndex).row; - } - - const newColumnIndex = getNewColumnIndex(nextRowIndex, columnIndex); - - return { - row: nextRowIndex, - column: newColumnIndex, - }; - }, - [getNewColumnIndex, rows], - ); - - const findPrevRow = useCallback( - (rowIndex: number, columnIndex: number): { row: number; column: number } => { - let prevRowIndex = rowIndex - 1; - - if (prevRowIndex < 1) { - prevRowIndex = 1; - } else if (rows[prevRowIndex].type === 'category') { - prevRowIndex = findPrevRow(prevRowIndex, columnIndex).row; - } - - const newColumnIndex = getNewColumnIndex(prevRowIndex, columnIndex); - - return { - row: prevRowIndex, - column: newColumnIndex, - }; - }, - [getNewColumnIndex, rows], - ); - - const findPrevCell = useCallback( - (row: number, column: number): { row: number; column: number } => { - const prevColumn = column - 1; - - if (prevColumn < 0) { - const prevRow = findPrevRow(row, column).row; - - if (prevRow === row) return { row, column }; - const length = rows[prevRow].emojis?.length || 0; - - return { - row: prevRow, - column: length > 0 ? length - 1 : 0, - }; - } - - return { - row, - column: prevColumn, - }; - }, - [findPrevRow, rows], - ); - - const findNextCell = useCallback( - (row: number, column: number): { row: number; column: number } => { - const nextColumn = column + 1; - - const rowLength = rows[row].emojis?.length || 0; - - if (nextColumn >= rowLength) { - const nextRow = findNextRow(row, column).row; - - if (nextRow === row) return { row, column }; - return { - row: nextRow, - column: 0, - }; - } - - return { - row, - column: nextColumn, - }; - }, - [findNextRow, rows], - ); - - useEffect(() => { - if (!selectCell || !scrollRef.current) return; - const emojiKey = rows[selectCell.row]?.emojis?.[selectCell.column]?.id; - const emojiDom = document.querySelector(`[data-key="${emojiKey}"]`); - - if (emojiDom && !inView(emojiDom as HTMLElement, scrollRef.current as HTMLElement)) { - const distance = getDistanceEdge(emojiDom as HTMLElement, scrollRef.current as HTMLElement); - - scrollRef.current?.scrollTo({ - top: scrollRef.current?.scrollTop + distance, - }); - } - }, [selectCell, rows]); - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - e.stopPropagation(); - - switch (e.key) { - case 'Escape': - e.preventDefault(); - onEscape?.(); - break; - case 'ArrowUp': { - e.preventDefault(); - - setSelectCell(findPrevRow(selectCell.row, selectCell.column)); - - break; - } - - case 'ArrowDown': { - e.preventDefault(); - - setSelectCell(findNextRow(selectCell.row, selectCell.column)); - - break; - } - - case 'ArrowLeft': { - e.preventDefault(); - - const prevCell = findPrevCell(selectCell.row, selectCell.column); - - setSelectCell(prevCell); - break; - } - - case 'ArrowRight': { - e.preventDefault(); - - const nextCell = findNextCell(selectCell.row, selectCell.column); - - setSelectCell(nextCell); - break; - } - - case 'Enter': { - e.preventDefault(); - const currentRow = rows[selectCell.row]; - const emoji = currentRow.emojis?.[selectCell.column]; - - if (emoji) { - onEmojiSelect(emoji.native); - } - - break; - } - - default: - break; - } - }, - [ - findNextCell, - findPrevCell, - findPrevRow, - findNextRow, - onEmojiSelect, - onEscape, - rows, - selectCell.column, - selectCell.row, - ], - ); - - useEffect(() => { - const focusElement = document.querySelector('.emoji-picker .search-emoji-input') as HTMLInputElement; - - const parentElement = ref.current?.parentElement; - - focusElement?.addEventListener('keydown', handleKeyDown); - parentElement?.addEventListener('keydown', handleKeyDown); - return () => { - focusElement?.removeEventListener('keydown', handleKeyDown); - parentElement?.removeEventListener('keydown', handleKeyDown); - }; - }, [handleKeyDown]); return (
- - {({ height, width }: { height: number; width: number }) => ( - - {renderRow} - - )} - +
); } diff --git a/src/components/_shared/emoji-picker/EmojiPickerHeader.tsx b/src/components/_shared/emoji-picker/EmojiPickerHeader.tsx index 4cb4bc6f..7ec313c7 100644 --- a/src/components/_shared/emoji-picker/EmojiPickerHeader.tsx +++ b/src/components/_shared/emoji-picker/EmojiPickerHeader.tsx @@ -1,41 +1,12 @@ -import { useSelectSkinPopoverProps } from './EmojiPicker.hooks'; -import React, { useCallback } from 'react'; -import { Button, OutlinedInput } from '@mui/material'; +import SwitchSkin from '@/components/_shared/emoji-picker/SwitchSkin'; +import { Button } from '@/components/ui/button'; +import { SearchInput } from '@/components/ui/search-input'; +import { TooltipContent, TooltipTrigger, Tooltip } from '@/components/ui/tooltip'; +import { useEffect, useRef } from 'react'; -import Tooltip from '@mui/material/Tooltip'; import { randomEmoji } from '@/utils/emoji'; import { ReactComponent as ShuffleIcon } from '@/assets/icons/shuffle.svg'; -import Popover from '@mui/material/Popover'; import { useTranslation } from 'react-i18next'; -import { ReactComponent as DeleteIcon } from '@/assets/icons/delete.svg'; -import { ReactComponent as SearchIcon } from '@/assets/icons/search.svg'; - -const skinTones = [ - { - value: 0, - icon: '👋', - }, - { - value: 1, - icon: '👋🏻', - }, - { - value: 2, - icon: '👋🏼', - }, - { - value: 3, - icon: '👋🏽', - }, - { - value: 4, - icon: '👋🏾', - }, - { - value: 5, - icon: '👋🏿', - }, -]; interface Props { onEmojiSelect: (emoji: string) => void; @@ -43,116 +14,60 @@ interface Props { onSkinSelect: (skin: number) => void; searchValue: string; onSearchChange: (value: string) => void; - hideRemove?: boolean; } -function EmojiPickerHeader({ hideRemove, onEmojiSelect, onSkinSelect, searchValue, onSearchChange, skin }: Props) { - const { onOpen, ...popoverProps } = useSelectSkinPopoverProps(); +function EmojiPickerHeader ({ onEmojiSelect, onSkinSelect, searchValue, onSearchChange, skin }: Props) { const { t } = useTranslation(); + const inputRef = useRef(null); - const renderButton = useCallback( - ({ - onClick, - tooltip, - children, - testId, - }: { - onClick: (e: React.MouseEvent) => void; - tooltip: string; - children: React.ReactNode; - testId?: string; - }) => { - return ( - - - - ); - }, - [] - ); + useEffect(() => { + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + }, []); return ( -
+
- } + { onSearchChange(e.target.value); }} + inputRef={inputRef} autoFocus={true} - fullWidth={true} - size={'small'} - autoCorrect={'off'} - autoComplete={'off'} - spellCheck={false} - inputProps={{ - className: 'px-2 py-1.5 text-base', - }} - className={'search-emoji-input'} + className={'search-emoji-input w-full'} placeholder={t('search.label')} /> +
- {renderButton({ - onClick: async () => { - const emoji = await randomEmoji(); - - onEmojiSelect(emoji); - }, - testId: 'random-emoji', - tooltip: t('emoji.random'), - children: , - })} - - {renderButton({ - onClick: onOpen, - tooltip: t('emoji.selectSkinTone'), - children: {skinTones[skin].icon}, - })} - - {hideRemove - ? null - : renderButton({ - onClick: () => { - onEmojiSelect(''); - }, - tooltip: t('emoji.remove'), - children: , - })} -
-
- -
- {skinTones.map((skinTone) => ( -
+ + -
- ))} + + + {t('emoji.random')} + + + + +
-
+
); } diff --git a/src/components/_shared/emoji-picker/EmojisVirtualizer.tsx b/src/components/_shared/emoji-picker/EmojisVirtualizer.tsx new file mode 100644 index 00000000..59e680b7 --- /dev/null +++ b/src/components/_shared/emoji-picker/EmojisVirtualizer.tsx @@ -0,0 +1,112 @@ +import EmojiItem from '@/components/_shared/emoji-picker/EmojiItem'; +import { Emoji } from '@/components/_shared/emoji-picker/EmojiPicker.hooks'; + +import React, { useCallback } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useTranslation } from 'react-i18next'; + +function EmojisVirtualizer ({ + onSelected, + data, +}: { + data: { + id: string + type: 'category' | 'emojis' + emojis?: Emoji[] | undefined + category?: string | undefined + }[]; + onSelected: (emoji: string) => void; +}) { + const { t } = useTranslation(); + const parentRef = React.useRef(null); + + const virtualizer = useVirtualizer({ + count: data.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 36, + overscan: 5, + }); + + const getCategoryName = useCallback( + (id: string) => { + const i18nName: Record = { + frequent: t('emoji.categories.frequentlyUsed'), + people: t('emoji.categories.people'), + nature: t('emoji.categories.nature'), + foods: t('emoji.categories.food'), + activity: t('emoji.categories.activities'), + places: t('emoji.categories.places'), + objects: t('emoji.categories.objects'), + symbols: t('emoji.categories.symbols'), + flags: t('emoji.categories.flags'), + }; + + return i18nName[id]; + }, + [t], + ); + + const items = virtualizer.getVirtualItems(); + + return ( +
+
+
+ {items.map((virtualRow) => ( +
+ {data[virtualRow.index].type === 'category' ? ( +
+ {getCategoryName( + data[virtualRow.index].id, + )} +
+ ) : ( + data[virtualRow.index].emojis?.map((emoji) => ( + { + onSelected(emoji.native); + }} + /> + )) + )} +
+ ))} +
+
+
+ ); +} + +export default EmojisVirtualizer; \ No newline at end of file diff --git a/src/components/_shared/emoji-picker/SwitchSkin.tsx b/src/components/_shared/emoji-picker/SwitchSkin.tsx new file mode 100644 index 00000000..ec40402b --- /dev/null +++ b/src/components/_shared/emoji-picker/SwitchSkin.tsx @@ -0,0 +1,88 @@ +import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +const skinTones = [ + { + value: 0, + icon: '👋', + }, + { + value: 1, + icon: '👋🏻', + }, + { + value: 2, + icon: '👋🏼', + }, + { + value: 3, + icon: '👋🏽', + }, + { + value: 4, + icon: '👋🏾', + }, + { + value: 5, + icon: '👋🏿', + }, +]; + +function SwitchSkin ({ skin, onSkinSelect }: { + skin: number; + onSkinSelect: (skin: number) => void; +}) { + const [open, setOpen] = React.useState(false); + const { t } = useTranslation(); + + return ( + + +
+ + + + + + {t('emoji.selectSkinTone')} + + +
+ +
+ +
+ {skinTones.map((item) => ( + + ))} +
+
+
+ + ); +} + +export default SwitchSkin; \ No newline at end of file diff --git a/src/components/_shared/file-dropzone/FileDropzone.tsx b/src/components/_shared/file-dropzone/FileDropzone.tsx index 24a9cc61..93dbab4d 100644 --- a/src/components/_shared/file-dropzone/FileDropzone.tsx +++ b/src/components/_shared/file-dropzone/FileDropzone.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; + import { ReactComponent as ImageIcon } from '@/assets/icons/image.svg'; interface FileDropzoneProps { @@ -64,22 +65,22 @@ function FileDropzone ({ onChange, accept, multiple, disabled, placeholder }: Fi return (
- -
+ +
{placeholder || t('fileDropzone.dropFile')}
diff --git a/src/components/_shared/gallery-preview/GalleryPreview.tsx b/src/components/_shared/gallery-preview/GalleryPreview.tsx index 320bba40..8e803274 100644 --- a/src/components/_shared/gallery-preview/GalleryPreview.tsx +++ b/src/components/_shared/gallery-preview/GalleryPreview.tsx @@ -1,16 +1,17 @@ -import { notify } from '@/components/_shared/notify'; -import { copyTextToClipboard } from '@/utils/copy'; import { IconButton, Portal, Tooltip } from '@mui/material'; import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { TransformWrapper, TransformComponent, ReactZoomPanPinchContentRef } from 'react-zoom-pan-pinch'; +import { ReactZoomPanPinchContentRef, TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch'; + import { ReactComponent as RightIcon } from '@/assets/icons/alt_arrow_right.svg'; -import { ReactComponent as ReloadIcon } from '@/assets/icons/reset.svg'; -import { ReactComponent as AddIcon } from '@/assets/icons/plus.svg'; -import { ReactComponent as MinusIcon } from '@/assets/icons/minus.svg'; -import { ReactComponent as LinkIcon } from '@/assets/icons/link.svg'; -import { ReactComponent as DownloadIcon } from '@/assets/icons/save_as.svg'; import { ReactComponent as CloseIcon } from '@/assets/icons/close.svg'; +import { ReactComponent as LinkIcon } from '@/assets/icons/link.svg'; +import { ReactComponent as MinusIcon } from '@/assets/icons/minus.svg'; +import { ReactComponent as AddIcon } from '@/assets/icons/plus.svg'; +import { ReactComponent as ReloadIcon } from '@/assets/icons/reset.svg'; +import { ReactComponent as DownloadIcon } from '@/assets/icons/save_as.svg'; +import { notify } from '@/components/_shared/notify'; +import { copyTextToClipboard } from '@/utils/copy'; export interface GalleryImage { src: string; @@ -96,8 +97,14 @@ function GalleryPreview({ images, open, onClose, previewIndex }: GalleryPreviewP } return ( - -
+ +
{ + e.stopPropagation(); + onClose?.(); + }} + > - + {index + 1}/{images.length} diff --git a/src/components/_shared/helmet/ViewHelmet.tsx b/src/components/_shared/helmet/ViewHelmet.tsx index b472cb0a..2351644e 100644 --- a/src/components/_shared/helmet/ViewHelmet.tsx +++ b/src/components/_shared/helmet/ViewHelmet.tsx @@ -1,6 +1,6 @@ import { ViewIcon, ViewIconType } from '@/application/types'; import { getIconBase64 } from '@/utils/emoji'; -import React, { useEffect } from 'react'; +import { useEffect } from 'react'; import { Helmet } from 'react-helmet'; function ViewHelmet ({ diff --git a/src/components/_shared/help/Help.tsx b/src/components/_shared/help/Help.tsx index 1ff8bf8e..145febbc 100644 --- a/src/components/_shared/help/Help.tsx +++ b/src/components/_shared/help/Help.tsx @@ -1,20 +1,20 @@ -import { notify } from '@/components/_shared/notify'; -import { Popover } from '@/components/_shared/popover'; -import { ThemeModeContext } from '@/components/main/useAppThemeMode'; -import { copyTextToClipboard } from '@/utils/copy'; -import { Button, Divider, Portal, Tooltip } from '@mui/material'; -import { PopoverProps } from '@mui/material/Popover'; -import { useContext } from 'react'; -import * as React from 'react'; -import Box from '@mui/material/Box'; -import { useTranslation } from 'react-i18next'; -import { ReactComponent as WhatsNewIcon } from '@/assets/icons/star.svg'; -import { ReactComponent as SupportIcon } from '@/assets/icons/help.svg'; import { ReactComponent as BugIcon } from '@/assets/icons/bug.svg'; import { ReactComponent as FeedbackIcon } from '@/assets/icons/feedback.svg'; -import { ReactComponent as MoonIcon } from '@/assets/icons/moon.svg'; -import { ReactComponent as SunIcon } from '@/assets/icons/sun.svg'; +import { ReactComponent as SupportIcon } from '@/assets/icons/help.svg'; import { ReactComponent as DocumentationIcon } from '@/assets/icons/help_&_documentation.svg'; +import { ReactComponent as MoonIcon } from '@/assets/icons/moon.svg'; +import { ReactComponent as WhatsNewIcon } from '@/assets/icons/star.svg'; +import { ReactComponent as SunIcon } from '@/assets/icons/sun.svg'; +import { ThemeModeContext } from '@/components/main/useAppThemeMode'; +import { notify } from '@/components/_shared/notify'; +import { Popover } from '@/components/_shared/popover'; +import { copyTextToClipboard } from '@/utils/copy'; +import { Button, Divider, Portal, Tooltip } from '@mui/material'; +import Box from '@mui/material/Box'; +import { PopoverProps } from '@mui/material/Popover'; +import * as React from 'react'; +import { useContext } from 'react'; +import { useTranslation } from 'react-i18next'; const popoverProps: Partial = { anchorOrigin: { @@ -40,7 +40,7 @@ export default function Help() {
setOpen(!open)} className={'py-2'}>
@@ -127,7 +127,7 @@ export default function Help() { target='_blank' component={'a'} href={'https://forum.appflowy.io/'} - className={'justify-start text-text-caption'} + className={'justify-start text-text-secondary'} color={'inherit'} variant={'text'} > @@ -138,7 +138,7 @@ export default function Help() { component={'a'} target='_blank' href={'https://x.com/appflowy'} - className={'justify-start text-text-caption'} + className={'justify-start text-text-secondary'} color={'inherit'} variant={'text'} > @@ -149,7 +149,7 @@ export default function Help() { component={'a'} target='_blank' href={'https://www.reddit.com/r/AppFlowy/'} - className={'justify-start text-text-caption'} + className={'justify-start text-text-secondary'} color={'inherit'} variant={'text'} > diff --git a/src/components/_shared/icon-picker/IconItem.tsx b/src/components/_shared/icon-picker/IconItem.tsx new file mode 100644 index 00000000..f0f21b73 --- /dev/null +++ b/src/components/_shared/icon-picker/IconItem.tsx @@ -0,0 +1,84 @@ +import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { IconColors, renderColor } from '@/utils/color'; +import { useMemo } from 'react'; + +function IconItem({ + icon, + onSelect, + enableColor = true, + container, +}: { + icon: { + id: string; + name: string; + content: string; + cleanSvg: string; + }; + onSelect: (icon: { value: string; color: string; content: string }) => void; + enableColor?: boolean; + container?: HTMLDivElement; +}) { + const trigger = useMemo(() => { + return ( + + + + + + {icon.name.replaceAll('-', ' ')} + + + ); + }, [enableColor, icon, onSelect, container]); + + if (!enableColor) { + return trigger; + } + + return ( + + +
{trigger}
+
+ + +
+ {IconColors.map((color) => ( + + ))} +
+
+
+ ); +} + +export default IconItem; diff --git a/src/components/_shared/icon-picker/IconPicker.tsx b/src/components/_shared/icon-picker/IconPicker.tsx index 864ed3ac..442da837 100644 --- a/src/components/_shared/icon-picker/IconPicker.tsx +++ b/src/components/_shared/icon-picker/IconPicker.tsx @@ -1,32 +1,30 @@ -import { Popover } from '@/components/_shared/popover'; -import { IconColors, randomColor, renderColor } from '@/utils/color'; -import { ICON_CATEGORY, loadIcons, randomIcon } from '@/utils/emoji'; -import { Button, OutlinedInput } from '@mui/material'; -import Tooltip from '@mui/material/Tooltip'; -import React, { useCallback, useEffect } from 'react'; import { ReactComponent as ShuffleIcon } from '@/assets/icons/shuffle.svg'; -import { ReactComponent as SearchIcon } from '@/assets/icons/search.svg'; -import { useTranslation } from 'react-i18next'; -import { VariableSizeList } from 'react-window'; -import AutoSizer from 'react-virtualized-auto-sizer'; +import { Button } from '@/components/ui/button'; +import { SearchInput } from '@/components/ui/search-input'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import IconsVirtualizer from '@/components/_shared/icon-picker/IconsVirtualizer'; +import { IconColors, randomColor } from '@/utils/color'; +import { ICON_CATEGORY, loadIcons, randomIcon } from '@/utils/emoji'; import DOMPurify from 'dompurify'; +import React, { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; const ICONS_PER_ROW = 9; -const ROW_HEIGHT = 42; -const CATEGORY_HEIGHT = 42; function IconPicker({ onSelect, onEscape, size, + enableColor = true, + container, }: { onSelect: (icon: { value: string; color: string; content: string }) => void; onEscape?: () => void; size?: [number, number]; + enableColor: boolean; + container?: HTMLDivElement; }) { const { t } = useTranslation(); - const [anchorEl, setAnchorEl] = React.useState(null); - const [selectIcon, setSelectIcon] = React.useState(null); const [icons, setIcons] = React.useState< | Record< ICON_CATEGORY, @@ -39,6 +37,13 @@ function IconPicker({ > | undefined >(undefined); + const inputRef = useRef(null); + + useEffect(() => { + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + }, []); const [searchValue, setSearchValue] = React.useState(''); const filteredIcons = React.useMemo(() => { if (!icons) return {}; @@ -99,93 +104,43 @@ function IconPicker({ return rows; }, [filteredIcons]); - const getRowHeight = (index: number) => { - const row = rowData[index]; - - return row.type === 'category' ? CATEGORY_HEIGHT : ROW_HEIGHT; - }; - useEffect(() => { void loadIcons().then(setIcons); }, []); - const Row = useCallback( - ({ data, index, style }: { data: typeof rowData; index: number; style: React.CSSProperties }) => { - const row = data[index]; - - if (row.type === 'category') { - return ( -
- {row.category} -
- ); - } - - if (!row.icons) return null; - - return ( -
- {row.icons.map((icon) => ( - - - - ))} -
- ); - }, - [] - ); - return (
-
-
- } - value={searchValue} - onChange={(e) => { - setSearchValue(e.target.value); - }} - onKeyUp={(e) => { - if (e.key === 'Escape' && onEscape) { - onEscape(); - } - }} - autoFocus={true} - fullWidth={true} - size={'small'} - autoCorrect={'off'} - autoComplete={'off'} - spellCheck={false} - inputProps={{ - className: 'px-2 py-1.5 text-base', - }} - className={'search-emoji-input'} - placeholder={t('search.label')} - /> -
- +
+ { + setSearchValue(e.target.value); + }} + inputRef={inputRef} + onKeyUp={(e) => { + if (e.key === 'Escape' && onEscape) { + onEscape(); + } + }} + autoFocus={true} + autoCorrect={'off'} + autoComplete={'off'} + spellCheck={false} + className={'search-emoji-input w-full'} + placeholder={t('search.label')} + /> +
+ + - -
+ + {t('emoji.random')} +
-
-
- - {({ height, width }: { height: number; width: number }) => ( - - {Row} - - )} - + ); } diff --git a/src/components/_shared/icon-picker/IconsVirtualizer.tsx b/src/components/_shared/icon-picker/IconsVirtualizer.tsx new file mode 100644 index 00000000..0d8e194c --- /dev/null +++ b/src/components/_shared/icon-picker/IconsVirtualizer.tsx @@ -0,0 +1,96 @@ +import IconItem from '@/components/_shared/icon-picker/IconItem'; + +import React from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; + +function IconsVirtualizer ({ + onSelected, + data, + enableColor = true, + container, +}: { + container?: HTMLDivElement; + enableColor?: boolean, + data: Array<{ + type: 'category' | 'icons'; + category?: string; + icons?: Array<{ + id: string; + name: string; + content: string; + keywords: string[]; + cleanSvg: string; + }>; + }>; + onSelected: (icon: { value: string; color: string; content: string }) => void; +}) { + const parentRef = React.useRef(null); + + const virtualizer = useVirtualizer({ + count: data.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 36, + overscan: 5, + }); + + const items = virtualizer.getVirtualItems(); + + return ( +
+
+
+ {items.map((virtualRow) => ( +
+ {data[virtualRow.index].type === 'category' ? ( +
+ {data[virtualRow.index].category} +
+ ) : ( + data[virtualRow.index].icons?.map((icon) => ( + + )) + )} +
+ ))} +
+
+
+ ); +} + +export default IconsVirtualizer; \ No newline at end of file diff --git a/src/components/_shared/image-upload/EmbedLink.tsx b/src/components/_shared/image-upload/EmbedLink.tsx index e0378759..2b4a6a74 100644 --- a/src/components/_shared/image-upload/EmbedLink.tsx +++ b/src/components/_shared/image-upload/EmbedLink.tsx @@ -1,8 +1,8 @@ +import { Input } from '@/components/ui/input'; import { processUrl } from '@/utils/url'; -import React, { useCallback, useState } from 'react'; -import TextField from '@mui/material/TextField'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import Button from '@mui/material/Button'; +import { Button } from '@/components/ui/button'; export function EmbedLink({ onDone, @@ -10,12 +10,18 @@ export function EmbedLink({ defaultLink, placeholder, validator, + focused, + onFocus, + onBlur, }: { defaultLink?: string; onDone?: (value: string) => void; onEscape?: () => void; placeholder?: string; validator?: (url: string) => boolean; + focused?: boolean; + onFocus?: () => void; + onBlur?: () => void; }) { const { t } = useTranslation(); @@ -32,48 +38,49 @@ export function EmbedLink({ setError(!urlValid || !customValid); }, - [setValue, setError, validator], + [setValue, setError, validator] ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if(e.key === 'Enter' && !error && value) { + if (e.key === 'Enter' && !error && value) { e.preventDefault(); e.stopPropagation(); onDone?.(value); } - if(e.key === 'Escape') { + if (e.key === 'Escape') { onEscape?.(); } }, - [error, onDone, onEscape, value], + [error, onDone, onEscape, value] ); + const inputRef = useRef(null); + + useEffect(() => { + if (focused) { + inputRef.current?.focus(); + } + }, [focused]); + return ( -
- + -
diff --git a/src/components/_shared/image-upload/Unsplash.tsx b/src/components/_shared/image-upload/Unsplash.tsx index 3f6013b1..d8a61604 100644 --- a/src/components/_shared/image-upload/Unsplash.tsx +++ b/src/components/_shared/image-upload/Unsplash.tsx @@ -1,11 +1,11 @@ import { openUrl } from '@/utils/url'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { createApi } from 'unsplash-js'; +import { CircularProgress } from '@mui/material'; import TextField from '@mui/material/TextField'; -import { useTranslation } from 'react-i18next'; import Typography from '@mui/material/Typography'; import debounce from 'lodash-es/debounce'; -import { CircularProgress } from '@mui/material'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { createApi } from 'unsplash-js'; const unsplash = createApi({ accessKey: '1WxD1JpMOUX86lZKKob4Ca0LMZPyO2rUmAgjpWm9Ids', @@ -13,7 +13,7 @@ const unsplash = createApi({ const SEARCH_DEBOUNCE_TIME = 500; -export function Unsplash ({ onDone, onEscape }: { onDone?: (value: string) => void; onEscape?: () => void }) { +export function Unsplash({ onDone, onEscape }: { onDone?: (value: string) => void; onEscape?: () => void }) { const { t } = useTranslation(); const [loading, setLoading] = useState(true); @@ -44,18 +44,20 @@ export function Unsplash ({ onDone, onEscape }: { onDone?: (value: string) => vo onEscape?.(); } }, - [onEscape], + [onEscape] ); const loadPhotos = useCallback(async (searchValue: string) => { const pages = 4; const perPage = 30; const requests = Array.from({ length: pages }, (_, i) => - searchValue ? unsplash.search.getPhotos({ - query: searchValue, - perPage, - page: i + 1, - }) : unsplash.photos.list({ perPage, page: i + 1 }), + searchValue + ? unsplash.search.getPhotos({ + query: searchValue, + perPage, + page: i + 1, + }) + : unsplash.photos.list({ perPage, page: i + 1 }) ); setError(''); @@ -86,7 +88,6 @@ export function Unsplash ({ onDone, onEscape }: { onDone?: (value: string) => vo setPhotos(photos); return photos; - }, []); const debounceSearchPhotos = useMemo(() => { @@ -101,11 +102,7 @@ export function Unsplash ({ onDone, onEscape }: { onDone?: (value: string) => vo }, [debounceSearchPhotos, searchValue]); return ( -
+
vo {loading ? (
-
{t('editor.loading')}
+
{t('editor.loading')}
) : error ? ( @@ -130,14 +127,9 @@ export function Unsplash ({ onDone, onEscape }: { onDone?: (value: string) => vo
{photos.length > 0 ? ( <> -
+
{photos.map((photo) => ( -
+
{ @@ -145,16 +137,16 @@ export function Unsplash ({ onDone, onEscape }: { onDone?: (value: string) => vo }} src={photo.thumb} alt={photo.alt ?? ''} - className={`absolute top-0 left-0 w-[128px] h-full rounded object-cover cursor-pointer hover:opacity-80 transition-opacity`} + className={`absolute left-0 top-0 h-full w-[128px] cursor-pointer rounded object-cover transition-opacity hover:opacity-80`} />
-
+
by { void openUrl(photo.user.link); }} - className={'underline cursor-pointer underline-offset-[3px] ml-2 hover:text-function-info'} + className={'ml-2 cursor-pointer underline underline-offset-[3px] hover:text-function-info'} > {photo.user.name} @@ -162,12 +154,14 @@ export function Unsplash ({ onDone, onEscape }: { onDone?: (value: string) => vo
))}
- + {t('findAndReplace.searchMore')} ) : ( - + {t('findAndReplace.noResult')} )} diff --git a/src/components/_shared/image-upload/UploadImage.tsx b/src/components/_shared/image-upload/UploadImage.tsx index 50d3ac6f..499ee713 100644 --- a/src/components/_shared/image-upload/UploadImage.tsx +++ b/src/components/_shared/image-upload/UploadImage.tsx @@ -6,52 +6,59 @@ import { useTranslation } from 'react-i18next'; export const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp']; -export function UploadImage({ onDone, uploadAction }: { +export function UploadImage({ + onDone, + uploadAction, +}: { onDone?: (url: string) => void; uploadAction?: (file: File) => Promise; }) { const { t } = useTranslation(); const [loading, setLoading] = React.useState(false); - const handleFileChange = useCallback(async(files: File[]) => { - setLoading(true); - const file = files[0]; + const handleFileChange = useCallback( + async (files: File[]) => { + setLoading(true); + const file = files[0]; - if(!file) return; + if (!file) return; - try { - const url = await uploadAction?.(file); + try { + const url = await uploadAction?.(file); - if(!url) { + if (!url) { + onDone?.(URL.createObjectURL(file)); + return; + } + + onDone?.(url); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); onDone?.(URL.createObjectURL(file)); - return; + } finally { + setLoading(false); } - - onDone?.(url); - // eslint-disable-next-line - } catch(e: any) { - notify.error(e.message); - onDone?.(URL.createObjectURL(file)); - } finally { - setLoading(false); - } - - }, [onDone, uploadAction]); + }, + [onDone, uploadAction] + ); return ( -
+
- {loading && -
+ {loading && ( +
-
} +
+ )}
- ); } diff --git a/src/components/_shared/image-upload/UploadTabs.tsx b/src/components/_shared/image-upload/UploadPopover.tsx similarity index 63% rename from src/components/_shared/image-upload/UploadTabs.tsx rename to src/components/_shared/image-upload/UploadPopover.tsx index f1103ec5..6deb9859 100644 --- a/src/components/_shared/image-upload/UploadTabs.tsx +++ b/src/components/_shared/image-upload/UploadPopover.tsx @@ -1,7 +1,6 @@ -import { Popover } from '@/components/_shared/popover'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs'; import React, { SyntheticEvent, useCallback, useEffect, useRef, useState } from 'react'; -import { PopoverProps } from '@mui/material/Popover'; import SwipeableViews from 'react-swipeable-views'; export enum TAB_KEY { @@ -23,16 +22,19 @@ export type TabOption = { uploadAction?: (file: File) => Promise; }; -export function UploadTabs ({ +export function UploadPopover({ tabOptions, - popoverProps, - containerStyle, extra, + children, + open = false, + onOpenChange, }: { containerStyle?: React.CSSProperties; tabOptions: TabOption[]; - popoverProps?: PopoverProps; extra?: React.ReactNode; + children: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { const [tabValue, setTabValue] = useState(() => { return tabOptions[0].key; @@ -46,14 +48,6 @@ export function UploadTabs ({ const onKeyDown = useCallback( (e: React.KeyboardEvent) => { - e.stopPropagation(); - - if (e.key === 'Escape') { - e.preventDefault(); - e.stopPropagation(); - popoverProps?.onClose?.({}, 'escapeKeyDown'); - } - if (e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); @@ -69,7 +63,7 @@ export function UploadTabs ({ }); } }, - [popoverProps, tabOptions], + [tabOptions] ); const ref = useRef(null); @@ -89,48 +83,31 @@ export function UploadTabs ({ if (tabValue === 'unsplash') { handleResize(); } - }, [tabValue]); return ( - -
-
+ + {children} + +
{tabOptions.map((tab) => { const { key, label } = tab; - return ; + return ; })} {extra}
-
+
- popoverProps?.onClose?.({}, 'escapeKeyDown')} - /> + + onOpenChange?.(false)} /> ); })}
-
+
); } diff --git a/src/components/_shared/image-upload/index.ts b/src/components/_shared/image-upload/index.ts index c2b4c555..bffa7436 100644 --- a/src/components/_shared/image-upload/index.ts +++ b/src/components/_shared/image-upload/index.ts @@ -1,4 +1,4 @@ export * from './Unsplash'; export * from './UploadImage'; export * from './EmbedLink'; -export * from './UploadTabs'; +export * from 'src/components/_shared/image-upload/UploadPopover'; diff --git a/src/components/_shared/katex-math/KatexMath.tsx b/src/components/_shared/katex-math/KatexMath.tsx index dce963a2..558ad161 100644 --- a/src/components/_shared/katex-math/KatexMath.tsx +++ b/src/components/_shared/katex-math/KatexMath.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import 'katex/dist/katex.min.css'; import { BlockMath, InlineMath } from 'react-katex'; import './index.css'; diff --git a/src/components/_shared/keyboard_navigation/KeyboardNavigation.tsx b/src/components/_shared/keyboard_navigation/KeyboardNavigation.tsx index fb8cf88c..07ccf1d0 100644 --- a/src/components/_shared/keyboard_navigation/KeyboardNavigation.tsx +++ b/src/components/_shared/keyboard_navigation/KeyboardNavigation.tsx @@ -1,7 +1,7 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { MenuItem, Typography } from '@mui/material'; -import { scrollIntoView } from './utils'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { scrollIntoView } from './utils'; /** * The option of the keyboard navigation @@ -53,7 +53,7 @@ export interface KeyboardNavigationProps { renderNoResult?: () => React.ReactNode; } -function KeyboardNavigation ({ +function KeyboardNavigation({ defaultFocusedKey, onPressRight, onPressLeft, @@ -198,7 +198,7 @@ function KeyboardNavigation ({ break; } }, - [flattenOptions, focusedKey, onConfirm, onEscape, onPressLeft, onPressRight, onPropsKeyDown], + [flattenOptions, focusedKey, onConfirm, onEscape, onPressLeft, onPressRight, onPropsKeyDown] ); const renderOption = useCallback( @@ -208,13 +208,10 @@ function KeyboardNavigation ({ const isFocused = focusedKey === option.key; return ( -
+
{hasChildren ? ( // render the group name - option.content &&
{option.content}
+ option.content &&
{option.content}
) : ( // render the option ({
); }, - [itemClassName, focusedKey, onConfirm, onFocus, itemStyle], + [itemClassName, focusedKey, onConfirm, onFocus, itemStyle] ); useEffect(() => { @@ -309,10 +306,7 @@ function KeyboardNavigation ({ ) : renderNoResult ? ( renderNoResult() ) : ( - + {t('findAndReplace.noResult')} )} diff --git a/src/components/_shared/mobile-outline/MobileOutline.tsx b/src/components/_shared/mobile-outline/MobileOutline.tsx index aa18ac1c..40368a4f 100644 --- a/src/components/_shared/mobile-outline/MobileOutline.tsx +++ b/src/components/_shared/mobile-outline/MobileOutline.tsx @@ -1,6 +1,5 @@ import { UIVariant, View } from '@/application/types'; import OutlineItem from './OutlineItem'; -import React from 'react'; export function MobileOutline ({ outline, diff --git a/src/components/_shared/mobile-outline/MobileOutlineWithCover.tsx b/src/components/_shared/mobile-outline/MobileOutlineWithCover.tsx index 37ddbf0d..c2237d15 100644 --- a/src/components/_shared/mobile-outline/MobileOutlineWithCover.tsx +++ b/src/components/_shared/mobile-outline/MobileOutlineWithCover.tsx @@ -1,14 +1,19 @@ import { View, ViewLayout } from '@/application/types'; -import { ViewIcon } from '@/components/_shared/view-icon'; import MobileRecentViewCover from '@/components/app/recent/MobileRecentViewCover'; import { ThemeModeContext } from '@/components/main/useAppThemeMode'; +import { ViewIcon } from '@/components/_shared/view-icon'; +import PageIcon from '@/components/_shared/view-icon/PageIcon'; import { Divider } from '@mui/material'; import dayjs from 'dayjs'; -import React, { useCallback, useContext, useMemo } from 'react'; +import { useCallback, useContext, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import PageIcon from '@/components/_shared/view-icon/PageIcon'; -function MobileOutlineWithCover ({ view, navigateToView, timePrefix, time }: { +function MobileOutlineWithCover({ + view, + navigateToView, + timePrefix, + time, +}: { view: View; navigateToView: (viewId: string) => void; timePrefix?: string; @@ -17,25 +22,28 @@ function MobileOutlineWithCover ({ view, navigateToView, timePrefix, time }: { const { t } = useTranslation(); const isDark = useContext(ThemeModeContext)?.isDark; - const getRelativeTime = useCallback((time: string) => { - const justNow = dayjs().diff(dayjs(time), 'minute') < 1; - const isToday = dayjs().isSame(dayjs(time), 'day'); - const isYesterday = dayjs().isSame(dayjs(time), 'day'); + const getRelativeTime = useCallback( + (time: string) => { + const justNow = dayjs().diff(dayjs(time), 'minute') < 1; + const isToday = dayjs().isSame(dayjs(time), 'day'); + const isYesterday = dayjs().isSame(dayjs(time), 'day'); - if (justNow) { - return t('time.justNow'); - } + if (justNow) { + return t('time.justNow'); + } - if (isToday) { - return dayjs(time).format('HH:mm'); - } + if (isToday) { + return dayjs(time).format('HH:mm'); + } - if (isYesterday) { - return t('time.yesterday'); - } + if (isYesterday) { + return t('time.yesterday'); + } - return dayjs(time).format('MMM d, YYYY'); - }, [t]); + return dayjs(time).format('MMM d, YYYY'); + }, + [t] + ); const viewIconProps = useMemo(() => { switch (view.layout) { @@ -69,7 +77,6 @@ function MobileOutlineWithCover ({ view, navigateToView, timePrefix, time }: { iconClassName: 'text-[#00C2FF]', bgColor: isDark ? '#658B9033' : '#EDFBFFCC', }; - } }, [view.layout, isDark]); @@ -77,42 +84,47 @@ function MobileOutlineWithCover ({ view, navigateToView, timePrefix, time }: { <>
{ void navigateToView(view.view_id); }} > -
-
- {view.icon && } -
- {view.name} -
+
+
+ {view.icon && ( + + )} +
{view.name}
- {view.last_edited_time &&
- {timePrefix || ''} - {getRelativeTime(time || view.last_edited_time)} -
} - + {view.last_edited_time && ( +
+ {timePrefix || ''} + {getRelativeTime(time || view.last_edited_time)} +
+ )}
- {view.extra?.cover && view.extra?.cover.type !== 'none' ? :
- -
} + {view.extra?.cover && view.extra?.cover.type !== 'none' ? ( + + ) : ( +
+ +
+ )}
); } -export default MobileOutlineWithCover; \ No newline at end of file +export default MobileOutlineWithCover; diff --git a/src/components/_shared/mobile-outline/OutlineItem.tsx b/src/components/_shared/mobile-outline/OutlineItem.tsx index 9ec3dd4f..5e29c258 100644 --- a/src/components/_shared/mobile-outline/OutlineItem.tsx +++ b/src/components/_shared/mobile-outline/OutlineItem.tsx @@ -1,10 +1,10 @@ import { UIVariant, View } from '@/application/types'; +import { ReactComponent as PrivateIcon } from '@/assets/icons/lock.svg'; import OutlineIcon from '@/components/_shared/outline/OutlineIcon'; import OutlineItemContent from '@/components/_shared/outline/OutlineItemContent'; import React, { useCallback, useMemo } from 'react'; -import { ReactComponent as PrivateIcon } from '@/assets/icons/lock.svg'; -function OutlineItem ({ +function OutlineItem({ view, level = 0, selectedViewId, @@ -14,55 +14,58 @@ function OutlineItem ({ view: View; level?: number; selectedViewId?: string; - navigateToView?: (viewId: string) => Promise + navigateToView?: (viewId: string) => Promise; variant?: UIVariant; }) { const selected = selectedViewId === view.view_id; const [isExpanded, setIsExpanded] = React.useState(false); const getIcon = useCallback(() => { - return ; - }, [isExpanded, level]); - const renderItem = useCallback((item: View) => { return ( -
-
- {item.children?.length ? getIcon() : null} - - - {item.is_private && } -
-
+ + + ); - }, [getIcon, level, navigateToView, variant, selected]); + }, [isExpanded, level]); + const renderItem = useCallback( + (item: View) => { + return ( +
+
+ {item.children?.length ? getIcon() : null} + + + {item.is_private && } +
+
+ ); + }, + [getIcon, level, navigateToView, variant, selected] + ); const children = useMemo(() => view.children || [], [view.children]); const renderChildren = useMemo(() => { - return
- {children - .map((item, index) => ( + return ( +
+ {children.map((item, index) => ( ))} -
; +
+ ); }, [children, isExpanded, level, navigateToView, selectedViewId, variant]); return ( @@ -83,4 +87,4 @@ function OutlineItem ({ ); } -export default OutlineItem; \ No newline at end of file +export default OutlineItem; diff --git a/src/components/_shared/mobile-topbar/MobileFolder.tsx b/src/components/_shared/mobile-topbar/MobileFolder.tsx index 0d7f4193..e345ee51 100644 --- a/src/components/_shared/mobile-topbar/MobileFolder.tsx +++ b/src/components/_shared/mobile-topbar/MobileFolder.tsx @@ -1,14 +1,14 @@ -import MobileMore from '@/components/_shared/mobile-topbar/MobileMore'; -import { AFScroller } from '@/components/_shared/scroller'; -import { ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs'; import { AppContext, useAppOutline, useAppViewId } from '@/components/app/app.hooks'; import MobileFavorite from '@/components/app/favorite/MobileFavorite'; import MobileRecent from '@/components/app/recent/MobileRecent'; import MobileWorkspaces from '@/components/app/workspaces/MobileWorkspaces'; +import MobileMore from '@/components/_shared/mobile-topbar/MobileMore'; +import { AFScroller } from '@/components/_shared/scroller'; +import { ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs'; import React, { useContext } from 'react'; import { useTranslation } from 'react-i18next'; -import MobileOutline from 'src/components/_shared/mobile-outline/MobileOutline'; import SwipeableViews from 'react-swipeable-views'; +import MobileOutline from 'src/components/_shared/mobile-outline/MobileOutline'; enum ViewTabsKey { Space, @@ -16,11 +16,7 @@ enum ViewTabsKey { Favorite, } -function MobileFolder ({ - onClose, -}: { - onClose: () => void; -}) { +function MobileFolder({ onClose }: { onClose: () => void }) { const outline = useAppOutline(); const viewId = useAppViewId(); const navigateToView = useContext(AppContext)?.toView; @@ -28,12 +24,9 @@ function MobileFolder ({ const { t } = useTranslation(); return ( - -
-
+ +
+
@@ -48,18 +41,9 @@ function MobileFolder ({ }} onChange={(_, value) => setSelectedTab(value)} > - - - + + +
@@ -71,31 +55,20 @@ function MobileFolder ({ height: '100%', }} > -
- - {outline && } +
+ {outline && ( + + )}
-
+
-
+
- ); } -export default MobileFolder; \ No newline at end of file +export default MobileFolder; diff --git a/src/components/_shared/mobile-topbar/MobileMore.tsx b/src/components/_shared/mobile-topbar/MobileMore.tsx index eed6c236..788e7c6e 100644 --- a/src/components/_shared/mobile-topbar/MobileMore.tsx +++ b/src/components/_shared/mobile-topbar/MobileMore.tsx @@ -1,12 +1,12 @@ -import { Popover } from '@/components/_shared/popover'; -import React, { useMemo } from 'react'; -import { ReactComponent as MoreIcon } from '@/assets/icons/settings_more.svg'; -import { Button, Divider, IconButton } from '@mui/material'; -import { ReactComponent as TemplateIcon } from '@/assets/icons/template.svg'; import { ReactComponent as TrashIcon } from '@/assets/icons/delete.svg'; +import { ReactComponent as SupportIcon } from '@/assets/icons/help.svg'; +import { ReactComponent as MoreIcon } from '@/assets/icons/settings_more.svg'; +import { ReactComponent as TemplateIcon } from '@/assets/icons/template.svg'; +import { Popover } from '@/components/_shared/popover'; +import { Button, Divider, IconButton } from '@mui/material'; +import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { ReactComponent as SupportIcon } from '@/assets/icons/help.svg'; function MobileMore({ onClose }: { onClose: () => void }) { const [openMore, setOpenMore] = React.useState(false); @@ -42,7 +42,7 @@ function MobileMore({ onClose }: { onClose: () => void }) { return ( <> setOpenMore(true)} size={'large'} className={'p-2'}> - + void }) { onClick={() => { navigate('/'); }} - className={'sticky top-0 z-[10] w-full bg-bg-body py-2 pb-0'} + className={'sticky top-0 z-[10] w-full bg-background-primary py-2 pb-0'} >
diff --git a/src/components/_shared/modal/CacheClearingDialog.tsx b/src/components/_shared/modal/CacheClearingDialog.tsx index 2f86dfec..a4a843e6 100644 --- a/src/components/_shared/modal/CacheClearingDialog.tsx +++ b/src/components/_shared/modal/CacheClearingDialog.tsx @@ -1,6 +1,5 @@ import IndexedDBCleaner from '@/components/_shared/modal/IndexedDBCleaner'; import NormalModal from '@/components/_shared/modal/NormalModal'; -import React from 'react'; import { DialogContent, DialogContentText, List, ListItem, ListItemText } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { ReactComponent as ErrorIcon } from '@/assets/icons/error.svg'; diff --git a/src/components/_shared/modal/ChangeAccount.tsx b/src/components/_shared/modal/ChangeAccount.tsx index cbfe4494..f4152b6f 100644 --- a/src/components/_shared/modal/ChangeAccount.tsx +++ b/src/components/_shared/modal/ChangeAccount.tsx @@ -1,9 +1,9 @@ -import NormalModal from '@/components/_shared/modal/NormalModal'; +import { ReactComponent as ErrorIcon } from '@/assets/icons/error.svg'; import { AFConfigContext, useCurrentUser } from '@/components/main/app.hooks'; -import React, { useContext } from 'react'; +import NormalModal from '@/components/_shared/modal/NormalModal'; +import { useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { ReactComponent as ErrorIcon } from '@/assets/icons/error.svg'; function ChangeAccount({ setModalOpened, @@ -39,7 +39,7 @@ function ChangeAccount({ } open={modalOpened} > -
+
{t('invitation.errorModal.description', { email: currentUser?.email, })} diff --git a/src/components/_shared/modal/IndexedDBCleaner.tsx b/src/components/_shared/modal/IndexedDBCleaner.tsx index af207ed4..e73e8f00 100644 --- a/src/components/_shared/modal/IndexedDBCleaner.tsx +++ b/src/components/_shared/modal/IndexedDBCleaner.tsx @@ -3,15 +3,7 @@ import { notify } from '@/components/_shared/notify'; import DeleteIcon from '@mui/icons-material/Delete'; import TaskAltRounded from '@mui/icons-material/TaskAltRounded'; -import { - Box, - Button, - Checkbox, - List, ListItemButton, - ListItemIcon, - ListItemText, - Typography -} from '@mui/material'; +import { Box, Button, Checkbox, List, ListItemButton, ListItemIcon, ListItemText, Typography } from '@mui/material'; import { useEffect, useState } from 'react'; const MAX_DELETE = 50; @@ -24,7 +16,7 @@ const IndexedDBCleaner = () => { const fetchDatabases = async () => { const dbs = await window.indexedDB.databases(); - setDatabases(dbs.map(db => db.name || '').filter(Boolean)); + setDatabases(dbs.map((db) => db.name || '').filter(Boolean)); }; void fetchDatabases(); @@ -58,7 +50,7 @@ const IndexedDBCleaner = () => { indexedDB.deleteDatabase(dbName); } - setDatabases(databases.filter(db => !selectedDbs.includes(db))); + setDatabases(databases.filter((db) => !selectedDbs.includes(db))); setSelectedDbs([]); notify.success('Selected databases deleted successfully.'); @@ -66,7 +58,7 @@ const IndexedDBCleaner = () => { const reduceCountdown = () => { setTimeout(() => { - setCountdown(prev => { + setCountdown((prev) => { if (prev === 0) return 0; return prev - 1; }); @@ -75,26 +67,20 @@ const IndexedDBCleaner = () => { }; reduceCountdown(); - }; if (databases.length === 0) return null; return ( -
- +
{databases.map((dbName) => { const labelId = `checkbox-list-label-${dbName}`; return ( - handleToggle(dbName)} - > + handleToggle(dbName)}> { })} - - Total: {databases.length} - - + Total: {databases.length} + Selected: {selectedDbs.length}/{MAX_DELETE}
- ))} - -
; + return ( +
+ {actions.map((action, index) => ( + + ))} +
+ ); }, [actions, itemClicked]); return ( <> {actionsContent} - setOpenConfirm(false)} - /> + setOpenConfirm(false)} /> ); } -export default MoreActionsContent; \ No newline at end of file +export default MoreActionsContent; diff --git a/src/components/_shared/more-actions/importer/Import.tsx b/src/components/_shared/more-actions/importer/Import.tsx index 30d64939..f8a0942c 100644 --- a/src/components/_shared/more-actions/importer/Import.tsx +++ b/src/components/_shared/more-actions/importer/Import.tsx @@ -1,10 +1,10 @@ +import { ReactComponent as CheckedIcon } from '@/assets/icons/check_circle.svg'; +import { LoginModal } from '@/components/login'; import { NormalModal } from '@/components/_shared/modal'; import ImporterModal from '@/components/_shared/more-actions/importer/ImporterModal'; import { useImport } from '@/components/_shared/more-actions/importer/useImport.hook'; -import { LoginModal } from '@/components/login'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ReactComponent as CheckedIcon } from '@/assets/icons/check_circle.svg'; function Import({ disableClose, onSuccessfulImport }: { disableClose?: boolean; onSuccessfulImport?: () => void }) { const { open, handleImportClose, handleLoginClose, loginOpen, url, source } = useImport(); @@ -47,7 +47,7 @@ function Import({ disableClose, onSuccessfulImport }: { disableClose?: boolean; closable={false} onClose={() => setOpenSuccess(false)} > -
{t('web.importSuccessMessage')}
+
{t('web.importSuccessMessage')}
)} diff --git a/src/components/_shared/more-actions/importer/ImporterDialogContent.tsx b/src/components/_shared/more-actions/importer/ImporterDialogContent.tsx index 5b85091e..fc1cef57 100644 --- a/src/components/_shared/more-actions/importer/ImporterDialogContent.tsx +++ b/src/components/_shared/more-actions/importer/ImporterDialogContent.tsx @@ -1,12 +1,12 @@ +import { AFConfigContext } from '@/components/main/app.hooks'; import FileDropzone from '@/components/_shared/file-dropzone/FileDropzone'; import { notify } from '@/components/_shared/notify'; import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs'; -import { AFConfigContext } from '@/components/main/app.hooks'; +import { ReactComponent as NotionIcon } from '@/assets/icons/notion.svg'; import LinearProgress from '@mui/material/LinearProgress'; import React, { useCallback, useContext } from 'react'; import { useTranslation } from 'react-i18next'; -import { ReactComponent as NotionIcon } from '@/assets/icons/notion.svg'; function ImporterDialogContent({ source, onSuccess }: { source?: string; onSuccess: () => void }) { const { t } = useTranslation(); @@ -33,7 +33,11 @@ function ImporterDialogContent({ source, onSuccess }: { source?: string; onSucce return (
- setValue(newValue)} value={value}> + setValue(newValue)} + value={value} + > ((props, ref) => { const { id, message, variant } = props; const { closeSnackbar } = useSnackbar(); const icons = { - success: , - error: , - warning: , - info: , + success: , + error: , + warning: , + info: , loading: null, default: null, }; const colors = { - success: 'border-green-300 border bg-bg-body', - error: 'bg-bg-body border-red-300 border', - warning: 'bg-bg-body border-yellow-300 border', - info: 'bg-bg-body border-blue-300 border', - default: 'bg-bg-body border border-content-blue-400', + success: 'border-green-300 border bg-background-primary', + error: 'bg-background-primary border-red-300 border', + warning: 'bg-background-primary border-yellow-300 border', + info: 'bg-background-primary border-blue-300 border', + default: 'bg-background-primary border border-content-blue-400', }; const [hovered, setHovered] = React.useState(false); @@ -37,20 +37,19 @@ const CustomSnackbar = React.forwardRef((pro onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} > -
-
- {icons[variant]} +
+
{icons[variant]}
+
+

{message}

-
-

{message}

-
- {hovered && closeSnackbar(id)} - > - - } - + {hovered && ( + closeSnackbar(id)} + > + + + )}
); diff --git a/src/components/_shared/notify/InfoSnackbar.tsx b/src/components/_shared/notify/InfoSnackbar.tsx index 023311c1..c1a243ae 100644 --- a/src/components/_shared/notify/InfoSnackbar.tsx +++ b/src/components/_shared/notify/InfoSnackbar.tsx @@ -1,5 +1,5 @@ import { notify } from '@/components/_shared/notify/index'; -import React, { forwardRef } from 'react'; +import { forwardRef } from 'react'; import { Button, IconButton, Paper } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { ReactComponent as CloseIcon } from '@/assets/icons/close.svg'; diff --git a/src/components/_shared/notify/index.ts b/src/components/_shared/notify/index.ts index d7108d61..00211a2a 100644 --- a/src/components/_shared/notify/index.ts +++ b/src/components/_shared/notify/index.ts @@ -1,20 +1,22 @@ -import { InfoProps } from '@/components/_shared/notify/InfoSnackbar'; import React, { lazy } from 'react'; +import { toast } from 'sonner'; + +import { InfoProps } from '@/components/_shared/notify/InfoSnackbar'; export const InfoSnackbar = lazy(() => import('./InfoSnackbar')); export const notify = { success: (message: string | React.ReactNode) => { - window.toast.success(message); + toast.success(message); }, error: (message: string | React.ReactNode) => { - window.toast.error(message); + toast.error(message); }, default: (message: string | React.ReactNode) => { - window.toast.default(message); + toast(message); }, warning: (message: string | React.ReactNode) => { - window.toast.warning(message); + toast.warning(message); }, info: (props: InfoProps) => { window.toast.info({ @@ -27,7 +29,7 @@ export const notify = { }); }, clear: () => { - window.toast.clear(); + toast.dismiss(); }, }; diff --git a/src/components/_shared/outline/Outline.tsx b/src/components/_shared/outline/Outline.tsx index 29cc0402..60610598 100644 --- a/src/components/_shared/outline/Outline.tsx +++ b/src/components/_shared/outline/Outline.tsx @@ -1,7 +1,7 @@ import { UIVariant, View } from '@/application/types'; import { DirectoryStructure } from '@/components/_shared/skeleton/DirectoryStructure'; import OutlineItem from '@/components/_shared/outline/OutlineItem'; -import React, { memo } from 'react'; +import { memo } from 'react'; export function Outline ({ outline, width, selectedViewId, navigateToView, variant }: { width: number; diff --git a/src/components/_shared/outline/OutlineDrawer.tsx b/src/components/_shared/outline/OutlineDrawer.tsx index 1c941b0e..ed51c192 100644 --- a/src/components/_shared/outline/OutlineDrawer.tsx +++ b/src/components/_shared/outline/OutlineDrawer.tsx @@ -1,14 +1,16 @@ +import { Drawer, IconButton, Tooltip } from '@mui/material'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; + +import { UIVariant } from '@/application/types'; import { ReactComponent as AppFlowyLogo } from '@/assets/icons/appflowy.svg'; import { ReactComponent as DoubleArrowLeft } from '@/assets/icons/double_arrow_left.svg'; import Resizer from '@/components/_shared/outline/Resizer'; -import { useNavigate } from 'react-router-dom'; -import AppFlowyPower from '../appflowy-power/AppFlowyPower'; -import { createHotKeyLabel, HOT_KEY_NAME } from '@/utils/hotkeys'; -import { Drawer, IconButton, Tooltip } from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import { UIVariant } from '@/application/types'; -import { useState } from 'react'; import { AFScroller } from '@/components/_shared/scroller'; +import { createHotKeyLabel, HOT_KEY_NAME } from '@/utils/hotkeys'; + +import AppFlowyPower from '../appflowy-power/AppFlowyPower'; export function OutlineDrawer({ onScroll, @@ -43,8 +45,9 @@ export function OutlineDrawer({ '& .MuiDrawer-paper': { width, boxSizing: 'border-box', - borderColor: 'var(--line-divider)', + borderColor: 'var(--border-primary)', boxShadow: 'none', + zIndex: 50, }, }} variant='persistent' @@ -55,7 +58,7 @@ export function OutlineDrawer({ PaperProps={{ sx: { borderRadius: 0, - background: variant === 'publish' ? 'var(--bg-body)' : 'var(--bg-base)', + background: variant === 'publish' ? 'var(--bg-body)' : 'var(--surface-container-layer-00)', }, }} > @@ -71,7 +74,7 @@ export function OutlineDrawer({ onMouseLeave={() => setHovered(false)} style={{ backdropFilter: variant === UIVariant.Publish ? 'blur(4px)' : undefined, - backgroundColor: variant === UIVariant.App ? 'var(--bg-base)' : undefined, + backgroundColor: variant === UIVariant.App ? 'var(--surface-container-layer-00)' : undefined, }} className={'sticky top-0 z-10 flex h-[48px] min-h-[48px] transform-gpu items-center justify-between'} > @@ -79,7 +82,7 @@ export function OutlineDrawer({ header ) : (
{ navigate('/app'); }} @@ -93,12 +96,12 @@ export function OutlineDrawer({ title={
{t('sideBar.closeSidebar')} - {createHotKeyLabel(HOT_KEY_NAME.TOGGLE_SIDEBAR)} + {createHotKeyLabel(HOT_KEY_NAME.TOGGLE_SIDEBAR)}
} > - + )} diff --git a/src/components/_shared/outline/OutlineIcon.tsx b/src/components/_shared/outline/OutlineIcon.tsx index 1e355b53..0e50c014 100644 --- a/src/components/_shared/outline/OutlineIcon.tsx +++ b/src/components/_shared/outline/OutlineIcon.tsx @@ -1,6 +1,10 @@ import { ReactComponent as ToggleListIcon } from '@/assets/icons/toggle_list.svg'; -function OutlineIcon ({ isExpanded, setIsExpanded, level }: { +function OutlineIcon({ + isExpanded, + setIsExpanded, + level, +}: { isExpanded: boolean; setIsExpanded: (isExpanded: boolean) => void; level: number; @@ -17,7 +21,7 @@ function OutlineIcon ({ isExpanded, setIsExpanded, level }: { }} className={'opacity-50 hover:opacity-100'} > - + ); } @@ -33,7 +37,7 @@ function OutlineIcon ({ isExpanded, setIsExpanded, level }: { setIsExpanded(true); }} > - + ); } diff --git a/src/components/_shared/outline/OutlineItem.tsx b/src/components/_shared/outline/OutlineItem.tsx index e1c93857..52f7e2c7 100644 --- a/src/components/_shared/outline/OutlineItem.tsx +++ b/src/components/_shared/outline/OutlineItem.tsx @@ -5,12 +5,19 @@ import OutlineItemContent from '@/components/_shared/outline/OutlineItemContent' import { getOutlineExpands, setOutlineExpands } from '@/components/_shared/outline/utils'; import React, { useCallback, useEffect, useMemo } from 'react'; -function OutlineItem({ view, level = 0, width, navigateToView, selectedViewId, variant }: { +function OutlineItem({ + view, + level = 0, + width, + navigateToView, + selectedViewId, + variant, +}: { view: View; width?: number; level?: number; selectedViewId?: string; - navigateToView?: (viewId: string) => Promise + navigateToView?: (viewId: string) => Promise; variant?: UIVariant; }) { const selected = selectedViewId === view.view_id; @@ -23,54 +30,59 @@ function OutlineItem({ view, level = 0, width, navigateToView, selectedViewId, v }, [isExpanded, view.view_id]); const getIcon = useCallback(() => { - return ; + return ( + + + + ); }, [isExpanded, level]); - const renderItem = useCallback((item: View) => { - return ( -
+ const renderItem = useCallback( + (item: View) => { + return (
- {item.children?.length ? getIcon() : null} +
+ {item.children?.length ? getIcon() : null} - - {item.is_private && } + + {item.is_private && } +
-
- ); - }, [variant, width, selected, getIcon, navigateToView, level]); + ); + }, + [variant, width, selected, getIcon, navigateToView, level] + ); const children = useMemo(() => view.children || [], [view.children]); const renderChildren = useMemo(() => { - return
- {children - .map((item, index) => ( + return ( +
+ {children.map((item, index) => ( ))} -
; +
+ ); }, [children, isExpanded, level, navigateToView, selectedViewId, width, variant]); return ( diff --git a/src/components/_shared/outline/OutlinePopover.tsx b/src/components/_shared/outline/OutlinePopover.tsx index d38a2a20..2c094c91 100644 --- a/src/components/_shared/outline/OutlinePopover.tsx +++ b/src/components/_shared/outline/OutlinePopover.tsx @@ -1,9 +1,9 @@ import AppFlowyPower from '@/components/_shared/appflowy-power/AppFlowyPower'; +import { RichTooltip } from '@/components/_shared/popover'; import { PopperPlacementType } from '@mui/material'; import React, { ReactElement, useMemo } from 'react'; -import { RichTooltip } from '@/components/_shared/popover'; -export function OutlinePopover ({ +export function OutlinePopover({ children, open, onClose, @@ -28,11 +28,10 @@ export function OutlinePopover ({
{content} {variant === 'publish' && } -
); }, [variant, onMouseEnter, onMouseLeave, content]); @@ -40,7 +39,7 @@ export function OutlinePopover ({ return ( = { diff --git a/src/components/_shared/popover/RichTooltip.tsx b/src/components/_shared/popover/RichTooltip.tsx index e67b8a38..3dd20509 100644 --- a/src/components/_shared/popover/RichTooltip.tsx +++ b/src/components/_shared/popover/RichTooltip.tsx @@ -10,7 +10,6 @@ interface Props { PaperProps?: { className?: string; }; - } export const RichTooltip = ({ placement = 'top', open, onClose, content, children, PaperProps }: Props) => { @@ -53,7 +52,9 @@ export const RichTooltip = ({ placement = 'top', open, onClose, content, childre + className={'m-2 overflow-hidden rounded-md border border-border-primary bg-background-primary'} + {...PaperProps} + > {content} diff --git a/src/components/_shared/progress/ComponentLoading.tsx b/src/components/_shared/progress/ComponentLoading.tsx index 9c96a189..0112766b 100644 --- a/src/components/_shared/progress/ComponentLoading.tsx +++ b/src/components/_shared/progress/ComponentLoading.tsx @@ -1,5 +1,4 @@ import CircularProgress from '@mui/material/CircularProgress'; -import React from 'react'; function ComponentLoading() { return ( diff --git a/src/components/_shared/progress/LinearProgressWithLabel.tsx b/src/components/_shared/progress/LinearProgressWithLabel.tsx index 0a4ba341..d5d7f535 100644 --- a/src/components/_shared/progress/LinearProgressWithLabel.tsx +++ b/src/components/_shared/progress/LinearProgressWithLabel.tsx @@ -1,6 +1,6 @@ -import React, { useMemo } from 'react'; +import { useMemo } from 'react'; -function LinearProgressWithLabel({ +export function LinearProgressWithLabel({ value, count, selectedCount, @@ -22,24 +22,28 @@ function LinearProgressWithLabel({ return (
-
- {options.map((option) => ( - - ))} +
+ {count > 0 ? ( + options.map((option) => ( + + )) + ) : ( + + )}
-
{result}
+
{result}
); } diff --git a/src/components/_shared/scroller/AFScroller.tsx b/src/components/_shared/scroller/AFScroller.tsx index bdb17db8..ceeb0064 100644 --- a/src/components/_shared/scroller/AFScroller.tsx +++ b/src/components/_shared/scroller/AFScroller.tsx @@ -1,17 +1,30 @@ -import { Scrollbars } from 'react-custom-scrollbars-2'; import React from 'react'; +import { Scrollbars } from 'react-custom-scrollbars-2'; -export interface AFScrollerProps { +import { cn } from '@/lib/utils'; + +export interface AFScrollerProps extends React.HTMLAttributes { children: React.ReactNode; overflowXHidden?: boolean; overflowYHidden?: boolean; className?: string; style?: React.CSSProperties; onScroll?: (e: React.UIEvent) => void; + setScrollableContainer?: (el: HTMLDivElement | null) => void; + hideScrollbars?: boolean; } export const AFScroller = React.forwardRef( - ({ onScroll, style, children, overflowXHidden, overflowYHidden, className }: AFScrollerProps, ref) => { + ({ + setScrollableContainer, + onScroll, + style, + children, + overflowXHidden, + overflowYHidden, + hideScrollbars, + className, + }: AFScrollerProps, ref) => { return (
} - renderThumbVertical={(props) =>
} + renderThumbHorizontal={(props) => +
} + renderThumbVertical={(props) => +
} {...(overflowXHidden && { renderTrackHorizontal: (props) => (
)} > {children} ); - } + }, ); diff --git a/src/components/_shared/skeleton/BreadcrumbSkeleton.tsx b/src/components/_shared/skeleton/BreadcrumbSkeleton.tsx index 4a5de9fe..29d52e77 100644 --- a/src/components/_shared/skeleton/BreadcrumbSkeleton.tsx +++ b/src/components/_shared/skeleton/BreadcrumbSkeleton.tsx @@ -1,17 +1,16 @@ -import React from 'react'; import { ReactComponent as RightIcon } from '@/assets/icons/alt_arrow_right.svg'; export const BreadcrumbsSkeleton = () => { return (
-
-
+
+
-
-
+
+
-
-
+
+
); }; diff --git a/src/components/_shared/skeleton/CalendarSkeleton.tsx b/src/components/_shared/skeleton/CalendarSkeleton.tsx index 8d2bc4d7..6bca09e5 100644 --- a/src/components/_shared/skeleton/CalendarSkeleton.tsx +++ b/src/components/_shared/skeleton/CalendarSkeleton.tsx @@ -1,59 +1,57 @@ -import React from 'react'; import TabbarSkeleton from '@/components/_shared/skeleton/TabbarSkeleton'; import TitleSkeleton from '@/components/_shared/skeleton/TitleSkeleton'; -function CalendarSkeleton ({ includeTitle = true, includeTabs = true }: { +function CalendarSkeleton({ + includeTitle = true, + includeTabs = true, +}: { includeTitle?: boolean; - includeTabs?: boolean + includeTabs?: boolean; }) { const daysInWeek = 7; const weeksInMonth = 4; return ( -
+
{includeTitle && ( <> -
+
)} - {includeTabs &&
- -
} + {includeTabs && ( +
+ +
+ )} {/* Calendar Header */} -
-
-
-
-
+
+
+
+
+
{/* Weekday Names */} -
+
{[...Array(daysInWeek)].map((_, index) => ( -
+
))}
{/* Calendar Grid */} -
-
+
+
{[...Array(weeksInMonth * daysInWeek)].map((_, index) => ( -
-
-
-
-
-
+
+
+
+
+
+
@@ -64,4 +62,4 @@ function CalendarSkeleton ({ includeTitle = true, includeTabs = true }: { ); } -export default CalendarSkeleton; \ No newline at end of file +export default CalendarSkeleton; diff --git a/src/components/_shared/skeleton/DirectoryStructure.tsx b/src/components/_shared/skeleton/DirectoryStructure.tsx index d5eff444..df5585a0 100644 --- a/src/components/_shared/skeleton/DirectoryStructure.tsx +++ b/src/components/_shared/skeleton/DirectoryStructure.tsx @@ -1,19 +1,17 @@ -import React from 'react'; - export const DirectoryStructure = () => { return ( -
+
-
+
-
+
-
+
@@ -22,10 +20,10 @@ export const DirectoryStructure = () => { }; const DirectoryItem = () => ( -
-
-
+
+
+
); -export default DirectoryStructure; \ No newline at end of file +export default DirectoryStructure; diff --git a/src/components/_shared/skeleton/DocumentSkeleton.tsx b/src/components/_shared/skeleton/DocumentSkeleton.tsx index 863e5c72..9639b6c1 100644 --- a/src/components/_shared/skeleton/DocumentSkeleton.tsx +++ b/src/components/_shared/skeleton/DocumentSkeleton.tsx @@ -1,29 +1,26 @@ -import React from 'react'; import TitleSkeleton from '@/components/_shared/skeleton/TitleSkeleton'; -function DocumentSkeleton () { +function DocumentSkeleton() { return ( -
-
+
+
-
+
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
); } -export default DocumentSkeleton; \ No newline at end of file +export default DocumentSkeleton; diff --git a/src/components/_shared/skeleton/EditorSkeleton.tsx b/src/components/_shared/skeleton/EditorSkeleton.tsx index 4fa334cc..16edecc0 100644 --- a/src/components/_shared/skeleton/EditorSkeleton.tsx +++ b/src/components/_shared/skeleton/EditorSkeleton.tsx @@ -1,14 +1,12 @@ -import React from 'react'; - -function EditorSkeleton () { +export function EditorSkeleton() { return ( -
-
-
-
-
-
-
+
+
+
+
+
+
+
); } diff --git a/src/components/_shared/skeleton/GridSkeleton.tsx b/src/components/_shared/skeleton/GridSkeleton.tsx index fdc80e1e..406ac3ec 100644 --- a/src/components/_shared/skeleton/GridSkeleton.tsx +++ b/src/components/_shared/skeleton/GridSkeleton.tsx @@ -1,8 +1,8 @@ -import React, { useEffect, useState } from 'react'; import TabbarSkeleton from '@/components/_shared/skeleton/TabbarSkeleton'; import TitleSkeleton from '@/components/_shared/skeleton/TitleSkeleton'; +import { useEffect, useState } from 'react'; -function GridSkeleton ({ includeTitle = true, includeTabs = true }: { includeTitle?: boolean; includeTabs?: boolean }) { +function GridSkeleton({ includeTitle = true, includeTabs = true }: { includeTitle?: boolean; includeTabs?: boolean }) { const [rows, setRows] = useState(3); const columns = 10; @@ -17,47 +17,42 @@ function GridSkeleton ({ includeTitle = true, includeTabs = true }: { includeTit }, []); return ( -
+
{includeTitle && ( <> -
+
- )} - {includeTabs &&
- -
} -
-
- + {includeTabs && ( +
+ +
+ )} +
+
+
- - {[...Array(columns)].map((_, index) => ( - - ))} - - - - {[...Array(rows)].map((_, rowIndex) => ( - - {[...Array(columns)].map((_, colIndex) => ( - + + {[...Array(columns)].map((_, index) => ( + ))} - ))} + + + {[...Array(rows)].map((_, rowIndex) => ( + + {[...Array(columns)].map((_, colIndex) => ( + + ))} + + ))}
-
-
-
-
+
+
+
+
@@ -66,4 +61,4 @@ function GridSkeleton ({ includeTitle = true, includeTabs = true }: { includeTit ); } -export default GridSkeleton; \ No newline at end of file +export default GridSkeleton; diff --git a/src/components/_shared/skeleton/KanbanSkeleton.tsx b/src/components/_shared/skeleton/KanbanSkeleton.tsx index 225ae50b..cfa44bf1 100644 --- a/src/components/_shared/skeleton/KanbanSkeleton.tsx +++ b/src/components/_shared/skeleton/KanbanSkeleton.tsx @@ -1,52 +1,47 @@ import TabbarSkeleton from '@/components/_shared/skeleton/TabbarSkeleton'; import TitleSkeleton from '@/components/_shared/skeleton/TitleSkeleton'; -import React from 'react'; -function KanbanSkeleton ({ - includeTitle = true, - includeTabs = true, -}: { - includeTitle?: boolean; - includeTabs?: boolean; -}) { +function KanbanSkeleton({ includeTitle = true, includeTabs = true }: { includeTitle?: boolean; includeTabs?: boolean }) { const columns = Math.max(Math.ceil(window.innerWidth / 420), 3); const cardsPerColumn = Math.max(Math.ceil(window.innerHeight / 300), 3); return ( -
+
{includeTitle && ( <> -
+
- )} - {includeTabs &&
- -
} + {includeTabs && ( +
+ +
+ )} -
-
+
+
{[...Array(columns)].map((_, columnIndex) => (
{/* Column title */} -
+
{/* Cards */} {[...Array(cardsPerColumn)].map((_, cardIndex) => ( -
-
-
-
-
-
+
+
+
+
+
+
))} @@ -58,4 +53,4 @@ function KanbanSkeleton ({ ); } -export default KanbanSkeleton; \ No newline at end of file +export default KanbanSkeleton; diff --git a/src/components/_shared/skeleton/RecentListSkeleton.tsx b/src/components/_shared/skeleton/RecentListSkeleton.tsx index 26ca31a2..c1d613a6 100644 --- a/src/components/_shared/skeleton/RecentListSkeleton.tsx +++ b/src/components/_shared/skeleton/RecentListSkeleton.tsx @@ -1,19 +1,14 @@ -import React from 'react'; - const RecentListSkeleton = ({ rows = 5 }) => { return ( -
+
{[...Array(rows)].map((_, index) => ( -
-
-
+
+
+
))}
); }; -export default RecentListSkeleton; \ No newline at end of file +export default RecentListSkeleton; diff --git a/src/components/_shared/skeleton/TabbarSkeleton.tsx b/src/components/_shared/skeleton/TabbarSkeleton.tsx index da1ce1cc..ab3bdfc5 100644 --- a/src/components/_shared/skeleton/TabbarSkeleton.tsx +++ b/src/components/_shared/skeleton/TabbarSkeleton.tsx @@ -1,16 +1,14 @@ -import React from 'react'; - -function TabbarSkeleton () { +function TabbarSkeleton() { const tabCount = 4; return ( -
-
-