diff --git a/frontend/appflowy_tauri/style-dictionary/tokens/base.json b/frontend/appflowy_tauri/style-dictionary/tokens/base.json index 4e31b0523d..fb58a867b1 100644 --- a/frontend/appflowy_tauri/style-dictionary/tokens/base.json +++ b/frontend/appflowy_tauri/style-dictionary/tokens/base.json @@ -7,7 +7,7 @@ "type": "color" }, "100": { - "value": "#edeef2", + "value": "#dadbdd", "type": "color" }, "200": { diff --git a/frontend/appflowy_web_app/cypress/fixtures/user_workspace.json b/frontend/appflowy_web_app/cypress/fixtures/user_workspace.json new file mode 100644 index 0000000000..6961f6f1c4 --- /dev/null +++ b/frontend/appflowy_web_app/cypress/fixtures/user_workspace.json @@ -0,0 +1,61 @@ +{ + "data": { + "user_profile": { + "uid": 304120109071339520, + "uuid": "cbff060a-196d-415a-aa80-759c01886466", + "email": "lu@appflowy.io", + "password": "", + "name": "Kilu", + "metadata": { + "icon_url": "🇽🇰" + }, + "encryption_sign": null, + "latest_workspace_id": "9eebea03-3ed5-4298-86b2-a7f77856d48b", + "updated_at": 1715847453 + }, + "visiting_workspace": { + "workspace_id": "9eebea03-3ed5-4298-86b2-a7f77856d48b", + "database_storage_id": "375874be-7a4f-4b7c-8b89-1dc9a39838f4", + "owner_uid": 304120109071339520, + "owner_name": "Kilu", + "workspace_type": 0, + "workspace_name": "Kilu Works", + "created_at": "2024-03-13T07:23:10.275174Z", + "icon": "😆" + }, + "workspaces": [ + { + "workspace_id": "81570fa8-8be9-4b2d-9f1c-1ef4f34079a8", + "database_storage_id": "6c1f1a2c-e8d5-4bc2-917f-495bce862abb", + "owner_uid": 311828434584080384, + "owner_name": "Zack Zi Xiang Fu", + "workspace_type": 0, + "workspace_name": "My Workspace", + "created_at": "2024-04-03T13:53:18.295918Z", + "icon": "" + }, + { + "workspace_id": "fcb503f9-9287-4de4-8de0-ea191e680968", + "database_storage_id": "ae1b82a5-2b93-45c7-901a-f9357c544534", + "owner_uid": 276169796100296704, + "owner_name": "Annie Anqi Wang", + "workspace_type": 0, + "workspace_name": "AppFlowy Test", + "created_at": "2023-12-27T04:18:36.372013Z", + "icon": "" + }, + { + "workspace_id": "9eebea03-3ed5-4298-86b2-a7f77856d48b", + "database_storage_id": "375874be-7a4f-4b7c-8b89-1dc9a39838f4", + "owner_uid": 304120109071339520, + "owner_name": "Kilu", + "workspace_type": 0, + "workspace_name": "Kilu Works", + "created_at": "2024-03-13T07:23:10.275174Z", + "icon": "😆" + } + ] + }, + "code": 0, + "message": "Operation completed successfully." +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/cypress/support/commands.ts b/frontend/appflowy_web_app/cypress/support/commands.ts index 6146bd1c01..b275a842c5 100644 --- a/frontend/appflowy_web_app/cypress/support/commands.ts +++ b/frontend/appflowy_web_app/cypress/support/commands.ts @@ -37,6 +37,7 @@ Cypress.Commands.add('mockAPI', () => { cy.intercept('POST', '/gotrue/token?grant_type=refresh_token', json).as('refreshToken'); }); cy.intercept('GET', '/api/user/profile', { fixture: 'user' }).as('getUserProfile'); + cy.intercept('GET', '/api/user/workspace', { fixture: 'user_workspace' }).as('getUserWorkspace'); }); // Example use: diff --git a/frontend/appflowy_web_app/index.html b/frontend/appflowy_web_app/index.html index 3548e9b85d..5480f37859 100644 --- a/frontend/appflowy_web_app/index.html +++ b/frontend/appflowy_web_app/index.html @@ -3,7 +3,9 @@ - + AppFlowy diff --git a/frontend/appflowy_web_app/package.json b/frontend/appflowy_web_app/package.json index 1acc7d6e82..2dafe5e66d 100644 --- a/frontend/appflowy_web_app/package.json +++ b/frontend/appflowy_web_app/package.json @@ -22,12 +22,13 @@ "test:unit": "jest" }, "dependencies": { - "@appflowyinc/client-api-wasm": "0.0.2-alpha.2", + "@appflowyinc/client-api-wasm": "0.0.3", "@atlaskit/primitives": "^5.5.3", "@emoji-mart/data": "^1.1.2", "@emoji-mart/react": "^1.1.1", "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", + "@jest/globals": "^29.7.0", "@mui/icons-material": "^5.11.11", "@mui/material": "6.0.0-alpha.2", "@mui/x-date-pickers-pro": "^6.18.2", @@ -35,9 +36,10 @@ "@slate-yjs/core": "^1.0.2", "@tauri-apps/api": "^1.5.3", "@types/react-swipeable-views": "^0.13.4", + "async-retry": "^1.3.3", "axios": "^1.6.8", "dayjs": "^1.11.9", - "dexie": "^4.0.1", + "decimal.js": "^10.4.3", "emoji-mart": "^5.5.2", "emoji-regex": "^10.2.1", "events": "^3.3.0", @@ -51,6 +53,7 @@ "katex": "^0.16.7", "lodash-es": "^4.17.21", "nanoid": "^4.0.0", + "numeral": "^2.0.6", "prismjs": "^1.29.0", "protoc-gen-ts": "0.8.7", "quill": "^1.3.7", @@ -66,6 +69,7 @@ "react-hot-toast": "^2.4.1", "react-i18next": "^14.1.0", "react-katex": "^3.0.1", + "react-measure": "^2.5.2", "react-redux": "^8.0.5", "react-router-dom": "^6.22.3", "react-swipeable-views": "^0.14.0", @@ -98,6 +102,7 @@ "@types/katex": "^0.16.0", "@types/lodash-es": "^4.17.11", "@types/node": "^20.11.30", + "@types/numeral": "^2.0.5", "@types/prismjs": "^1.26.0", "@types/quill": "^2.0.10", "@types/react": "^18.2.66", @@ -107,6 +112,7 @@ "@types/react-datepicker": "^4.19.3", "@types/react-dom": "^18.2.22", "@types/react-katex": "^3.0.0", + "@types/react-measure": "^2.0.12", "@types/react-transition-group": "^4.4.6", "@types/react-window": "^1.8.8", "@types/utf8": "^3.0.1", diff --git a/frontend/appflowy_web_app/pnpm-lock.yaml b/frontend/appflowy_web_app/pnpm-lock.yaml index b9fe83de2f..770298d3b9 100644 --- a/frontend/appflowy_web_app/pnpm-lock.yaml +++ b/frontend/appflowy_web_app/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: dependencies: '@appflowyinc/client-api-wasm': - specifier: 0.0.2-alpha.2 - version: 0.0.2-alpha.2 + specifier: 0.0.3 + version: 0.0.3 '@atlaskit/primitives': specifier: ^5.5.3 version: 5.5.3(@types/react@18.2.66)(react@18.2.0) @@ -23,6 +23,9 @@ dependencies: '@emotion/styled': specifier: ^11.10.6 version: 11.10.6(@emotion/react@11.10.6)(@types/react@18.2.66)(react@18.2.0) + '@jest/globals': + specifier: ^29.7.0 + version: 29.7.0 '@mui/icons-material': specifier: ^5.11.11 version: 5.11.11(@mui/material@6.0.0-alpha.2)(@types/react@18.2.66)(react@18.2.0) @@ -44,15 +47,18 @@ dependencies: '@types/react-swipeable-views': specifier: ^0.13.4 version: 0.13.4 + async-retry: + specifier: ^1.3.3 + version: 1.3.3 axios: specifier: ^1.6.8 version: 1.6.8 dayjs: specifier: ^1.11.9 version: 1.11.9 - dexie: - specifier: ^4.0.1 - version: 4.0.1 + decimal.js: + specifier: ^10.4.3 + version: 10.4.3 emoji-mart: specifier: ^5.5.2 version: 5.5.2 @@ -92,6 +98,9 @@ dependencies: nanoid: specifier: ^4.0.0 version: 4.0.0 + numeral: + specifier: ^2.0.6 + version: 2.0.6 prismjs: specifier: ^1.29.0 version: 1.29.0 @@ -137,6 +146,9 @@ dependencies: react-katex: specifier: ^3.0.1 version: 3.0.1(prop-types@15.8.1)(react@18.2.0) + react-measure: + specifier: ^2.5.2 + version: 2.5.2(react-dom@18.2.0)(react@18.2.0) react-redux: specifier: ^8.0.5 version: 8.0.5(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1) @@ -229,6 +241,9 @@ devDependencies: '@types/node': specifier: ^20.11.30 version: 20.11.30 + '@types/numeral': + specifier: ^2.0.5 + version: 2.0.5 '@types/prismjs': specifier: ^1.26.0 version: 1.26.0 @@ -256,6 +271,9 @@ devDependencies: '@types/react-katex': specifier: ^3.0.0 version: 3.0.0 + '@types/react-measure': + specifier: ^2.0.12 + version: 2.0.12 '@types/react-transition-group': specifier: ^4.4.6 version: 4.4.6 @@ -376,8 +394,8 @@ packages: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 - /@appflowyinc/client-api-wasm@0.0.2-alpha.2: - resolution: {integrity: sha512-BcRK06zHHJdaGNYohYxGaR2xPfQ1RwU48jMzdMZDf2HXVLU2WWQ6cYfuM4lrsK+O3QEfJdeEL2fntnQDaaeQng==} + /@appflowyinc/client-api-wasm@0.0.3: + resolution: {integrity: sha512-ARjLhiDZ8MiZ9egWDbAX9VAdXXS30av+InCPLrS/iqCMYrhuuU9rxS9jQeNEB7jucFrj158gBRusimFN7P/lyw==} dev: false /@atlaskit/analytics-next-stable-react-context@1.0.1(react@18.2.0): @@ -2677,6 +2695,10 @@ packages: dependencies: undici-types: 5.26.5 + /@types/numeral@2.0.5: + resolution: {integrity: sha512-kH8I7OSSwQu9DS9JYdFWbuvhVzvFRoCPCkGxNwoGgaPeDfEPJlcxNvEOypZhQ3XXHsGbfIuYcxcJxKUfJHnRfw==} + dev: true + /@types/parse-json@4.0.2: resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} dev: false @@ -2737,6 +2759,12 @@ packages: '@types/react': 18.2.66 dev: true + /@types/react-measure@2.0.12: + resolution: {integrity: sha512-Y6V11CH6bU7RhqrIdENPwEUZlPXhfXNGylMNnGwq5TAEs2wDoBA3kSVVM/EQ8u72sz5r9ja+7W8M8PIVcS841Q==} + dependencies: + '@types/react': 18.2.66 + dev: true + /@types/react-redux@7.1.33: resolution: {integrity: sha512-NF8m5AjWCkert+fosDsN3hAlHzpjSiXlVy9EgQEmLoBhaNXbmyeGs/aj5dQzKuF+/q+S7JQagorGDW8pJ28Hmg==} dependencies: @@ -3234,6 +3262,12 @@ packages: engines: {node: '>=8'} dev: true + /async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + dependencies: + retry: 0.13.1 + dev: false + /async@3.2.5: resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} dev: true @@ -4015,7 +4049,6 @@ packages: /decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} - dev: true /dedent@1.5.1: resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==} @@ -4101,10 +4134,6 @@ packages: minimist: 1.2.8 dev: true - /dexie@4.0.1: - resolution: {integrity: sha512-wSNn+TcCh+DuE2pdg058K3MhxA4g+IiZlW7yGz4cMd/t3z2rJXZcV3HDxZljbrICU2Iq0qY4UHnbolTMK/+bcA==} - dev: false - /didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} dev: true @@ -4875,6 +4904,10 @@ packages: has-symbols: 1.0.3 hasown: 2.0.2 + /get-node-dimensions@1.2.1: + resolution: {integrity: sha512-2MSPMu7S1iOTL+BOa6K1S62hB2zUAYNF/lV0gSVlOaacd087lc6nR1H1r0e3B1CerTo+RceOmi1iJW+vp21xcQ==} + dev: false + /get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} @@ -6384,6 +6417,10 @@ packages: boolbase: 1.0.0 dev: true + /numeral@2.0.6: + resolution: {integrity: sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==} + dev: false + /nwsapi@2.2.7: resolution: {integrity: sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==} dev: true @@ -7138,6 +7175,20 @@ packages: resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} dev: false + /react-measure@2.5.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-M+rpbTLWJ3FD6FXvYV6YEGvQ5tMayQ3fGrZhRPHrE9bVlBYfDCLuDcgNttYfk8IqfOI03jz6cbpqMRTUclQnaA==} + peerDependencies: + react: '>0.13.0' + react-dom: '>0.13.0' + dependencies: + '@babel/runtime': 7.24.4 + get-node-dimensions: 1.2.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + resize-observer-polyfill: 1.5.1 + dev: false + /react-onclickoutside@6.13.0(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-ty8So6tcUpIb+ZE+1HAhbLROvAIJYyJe/1vRrrcmW+jLsaM+/powDRqxzo6hSh9CuRZGSL1Q8mvcF5WRD93a0A==} peerDependencies: @@ -7452,6 +7503,10 @@ packages: resolution: {integrity: sha512-D72j2ubjgHpvuCiORWkOUxndHJrxDaSolheiz5CO+roz8ka97/4msh2E8F5qay4GawR5vzBt5MkbDHT+Rdy/Wg==} dev: false + /resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + dev: false + /resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -7495,6 +7550,11 @@ packages: signal-exit: 3.0.7 dev: true + /retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + dev: false + /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} diff --git a/frontend/appflowy_web_app/src/application/collab.type.ts b/frontend/appflowy_web_app/src/application/collab.type.ts index 0df2729749..9a2bcfe186 100644 --- a/frontend/appflowy_web_app/src/application/collab.type.ts +++ b/frontend/appflowy_web_app/src/application/collab.type.ts @@ -1,4 +1,4 @@ -import Y from 'yjs'; +import * as Y from 'yjs'; export type BlockId = string; @@ -8,6 +8,10 @@ export type ChildrenId = string; export type ViewId = string; +export type RowId = string; + +export type CellId = string; + export enum BlockType { Paragraph = 'paragraph', Page = 'page', @@ -192,6 +196,51 @@ export enum YjsFolderKey { type = 'ty', value = 'value', layout = 'layout', + bid = 'bid', +} + +export enum YjsDatabaseKey { + views = 'views', + id = 'id', + metas = 'metas', + fields = 'fields', + is_primary = 'is_primary', + last_modified = 'last_modified', + created_at = 'created_at', + name = 'name', + type = 'ty', + type_option = 'type_option', + content = 'content', + data = 'data', + iid = 'iid', + database_id = 'database_id', + field_orders = 'field_orders', + field_settings = 'field_settings', + visibility = 'visibility', + wrap = 'wrap', + width = 'width', + filters = 'filters', + groups = 'groups', + layout = 'layout', + layout_settings = 'layout_settings', + modified_at = 'modified_at', + row_orders = 'row_orders', + sorts = 'sorts', + height = 'height', + cells = 'cells', + field_type = 'field_type', + end_timestamp = 'end_timestamp', + include_time = 'include_time', + is_range = 'is_range', + reminder_id = 'reminder_id', + time_format = 'time_format', + date_format = 'date_format', + calculations = 'calculations', + field_id = 'field_id', + calculation_value = 'calculation_value', + condition = 'condition', + format = 'format', + filter_type = 'filter_type', } export interface YDoc extends Y.Doc { @@ -199,11 +248,54 @@ export interface YDoc extends Y.Doc { getMap(key: YjsEditorKey.data_section): YSharedRoot | any; } +export interface YDatabaseRow extends Y.Map { + get(key: YjsDatabaseKey.id): RowId; + + get(key: YjsDatabaseKey.height): string; + + get(key: YjsDatabaseKey.visibility): boolean; + + get(key: YjsDatabaseKey.created_at): CreatedAt; + + get(key: YjsDatabaseKey.last_modified): LastModified; + + get(key: YjsDatabaseKey.cells): YDatabaseCells; +} + +export interface YDatabaseCells extends Y.Map { + get(key: FieldId): YDatabaseCell; +} + +export type EndTimestamp = string; +export type ReminderId = string; + +export interface YDatabaseCell extends Y.Map { + get(key: YjsDatabaseKey.created_at): CreatedAt; + + get(key: YjsDatabaseKey.last_modified): LastModified; + + get(key: YjsDatabaseKey.field_type): string; + + get(key: YjsDatabaseKey.data): object | string | boolean | number; + + get(key: YjsDatabaseKey.end_timestamp): EndTimestamp; + + get(key: YjsDatabaseKey.include_time): boolean; + + // eslint-disable-next-line @typescript-eslint/unified-signatures + get(key: YjsDatabaseKey.is_range): boolean; + + get(key: YjsDatabaseKey.reminder_id): ReminderId; +} + export interface YSharedRoot extends Y.Map { get(key: YjsEditorKey.document): YDocument; - // eslint-disable-next-line @typescript-eslint/unified-signatures get(key: YjsEditorKey.folder): YFolder; + + get(key: YjsEditorKey.database): YDatabase; + + get(key: YjsEditorKey.database_row): YDatabaseRow; } export interface YFolder extends Y.Map { @@ -226,6 +318,9 @@ export interface YViews extends Y.Map { export interface YView extends Y.Map { get(key: YjsFolderKey.id): ViewId; + get(key: YjsFolderKey.bid): string; + + // eslint-disable-next-line @typescript-eslint/unified-signatures get(key: YjsFolderKey.name): string; // eslint-disable-next-line @typescript-eslint/unified-signatures @@ -271,6 +366,166 @@ export interface YTextMap extends Y.Map { get(key: ExternalId): Y.Text; } +export interface YDatabase extends Y.Map { + get(key: YjsDatabaseKey.views): YDatabaseViews; + + get(key: YjsDatabaseKey.metas): YDatabaseMetas; + + get(key: YjsDatabaseKey.fields): YDatabaseFields; + + get(key: YjsDatabaseKey.id): string; +} + +export interface YDatabaseViews extends Y.Map { + get(key: ViewId): YDatabaseView; +} + +export type DatabaseId = string; +export type CreatedAt = string; +export type LastModified = string; +export type ModifiedAt = string; +export type FieldId = string; + +export enum DatabaseViewLayout { + Grid = 0, + Board = 1, + Calendar = 2, +} + +export interface YDatabaseView extends Y.Map { + get(key: YjsDatabaseKey.database_id): DatabaseId; + + get(key: YjsDatabaseKey.name): string; + + get(key: YjsDatabaseKey.created_at): CreatedAt; + + get(key: YjsDatabaseKey.modified_at): ModifiedAt; + + // eslint-disable-next-line @typescript-eslint/unified-signatures + get(key: YjsDatabaseKey.layout): string; + + get(key: YjsDatabaseKey.layout_settings): YDatabaseLayoutSettings; + + get(key: YjsDatabaseKey.filters): YDatabaseFilters; + + get(key: YjsDatabaseKey.groups): YDatabaseGroups; + + get(key: YjsDatabaseKey.sorts): YDatabaseSorts; + + get(key: YjsDatabaseKey.field_settings): YDatabaseFieldSettings; + + get(key: YjsDatabaseKey.field_orders): YDatabaseFieldOrders; + + get(key: YjsDatabaseKey.row_orders): YDatabaseRowOrders; + + get(key: YjsDatabaseKey.calculations): YDatabaseCalculations; +} + +export type YDatabaseFieldOrders = Y.Array; // [ { id: FieldId } ] + +export type YDatabaseRowOrders = Y.Array; // [ { id: RowId, height: number } ] + +export type YDatabaseGroups = Y.Array; + +export type YDatabaseFilters = Y.Array; + +export type YDatabaseSorts = Y.Array; + +export type YDatabaseLayoutSettings = Y.Map; + +export type YDatabaseCalculations = Y.Array; + +export type SortId = string; + +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; + + get(key: YjsDatabaseKey.field_id): FieldId; + + get(key: YjsDatabaseKey.condition): string; +} + +export type FilterId = string; + +export interface YDatabaseFilter extends Y.Map { + get(key: YjsDatabaseKey.id): FilterId; + + get(key: YjsDatabaseKey.field_id): FieldId; + + get(key: YjsDatabaseKey.type | YjsDatabaseKey.condition | YjsDatabaseKey.content | YjsDatabaseKey.filter_type): string; +} + +export interface YDatabaseCalculation extends Y.Map { + get(key: YjsDatabaseKey.field_id): FieldId; + + get(key: YjsDatabaseKey.id | YjsDatabaseKey.type | YjsDatabaseKey.calculation_value): string; +} + +export interface YDatabaseFieldSettings extends Y.Map { + get(key: FieldId): YDatabaseFieldSetting; +} + +export interface YDatabaseFieldSetting extends Y.Map { + get(key: YjsDatabaseKey.visibility): string; + + get(key: YjsDatabaseKey.wrap): boolean; + + // eslint-disable-next-line @typescript-eslint/unified-signatures + get(key: YjsDatabaseKey.width): string; +} + +export interface YDatabaseMetas extends Y.Map { + get(key: YjsDatabaseKey.iid): string; +} + +export interface YDatabaseFields extends Y.Map { + get(key: FieldId): YDatabaseField; +} + +export interface YDatabaseField extends Y.Map { + get(key: YjsDatabaseKey.name): string; + + get(key: YjsDatabaseKey.id): FieldId; + + // eslint-disable-next-line @typescript-eslint/unified-signatures + get(key: YjsDatabaseKey.type): string; + + get(key: YjsDatabaseKey.type_option): YDatabaseFieldTypeOption; + + get(key: YjsDatabaseKey.is_primary): boolean; + + get(key: YjsDatabaseKey.last_modified): LastModified; +} + +export interface YDatabaseFieldTypeOption extends Y.Map { + // key is the field type + get(key: string): YMapFieldTypeOption; +} + +export interface YMapFieldTypeOption extends Y.Map { + get(key: YjsDatabaseKey.content): string; + + // eslint-disable-next-line @typescript-eslint/unified-signatures + get(key: YjsDatabaseKey.data): string; + + // eslint-disable-next-line @typescript-eslint/unified-signatures + get(key: YjsDatabaseKey.time_format): string; + + // eslint-disable-next-line @typescript-eslint/unified-signatures + get(key: YjsDatabaseKey.date_format): string; + + get(key: YjsDatabaseKey.database_id): DatabaseId; + + // eslint-disable-next-line @typescript-eslint/unified-signatures + get(key: YjsDatabaseKey.format): string; +} + export enum CollabType { Document = 0, Database = 1, @@ -282,8 +537,12 @@ export enum CollabType { } export enum CollabOrigin { + // from local changes and never sync to remote. used for read-only mode Local = 'local', + // from remote changes and never sync to remote. Remote = 'remote', + // from local changes and sync to remote. used for collaborative mode + LocalSync = 'local_sync', } export const layoutMap = { @@ -292,3 +551,9 @@ export const layoutMap = { [ViewLayout.Board]: 'board', [ViewLayout.Calendar]: 'calendar', }; + +export const databaseLayoutMap = { + [DatabaseViewLayout.Grid]: 'grid', + [DatabaseViewLayout.Board]: 'board', + [DatabaseViewLayout.Calendar]: 'calendar', +}; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/const.ts b/frontend/appflowy_web_app/src/application/database-yjs/const.ts new file mode 100644 index 0000000000..b082acc6a4 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/const.ts @@ -0,0 +1,2 @@ +export const DEFAULT_ROW_HEIGHT = 37; +export const MIN_COLUMN_WIDTH = 100; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/context.ts b/frontend/appflowy_web_app/src/application/database-yjs/context.ts new file mode 100644 index 0000000000..8717aa0ffe --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/context.ts @@ -0,0 +1,127 @@ +import { YDatabase, YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type'; +import { filterBy } from '@/application/database-yjs/filter'; +import { Row } from '@/application/database-yjs/selector'; +import { sortBy } from '@/application/database-yjs/sort'; +import { createContext, useContext, useEffect, useState } from 'react'; +import * as Y from 'yjs'; +import debounce from 'lodash-es/debounce'; + +export interface DatabaseContextState { + readOnly: boolean; + doc: YDoc; + viewId: string; + rowDocMap: Y.Map; +} + +export const DatabaseContext = createContext(null); + +export const useDatabase = () => { + const database = useContext(DatabaseContext) + ?.doc?.getMap(YjsEditorKey.data_section) + .get(YjsEditorKey.database) as YDatabase; + + return database; +}; + +export const useRowMeta = (rowId: string) => { + const rows = useContext(DatabaseContext)?.rowDocMap; + const rowMetaDoc = rows?.get(rowId); + const rowMeta = rowMetaDoc?.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database_row) as YDatabaseRow; + + return rowMeta; +}; + +export const useViewId = () => { + const context = useContext(DatabaseContext); + + return context?.viewId; +}; + +export const useReadOnly = () => { + const context = useContext(DatabaseContext); + + return context?.readOnly; +}; + +export const useDatabaseView = () => { + const database = useDatabase(); + const viewId = useViewId(); + + return viewId ? database.get(YjsDatabaseKey.views)?.get(viewId) : undefined; +}; + +export function useDatabaseFields() { + const database = useDatabase(); + + return database.get(YjsDatabaseKey.fields); +} + +export interface GridRowsState { + rowOrders: Row[]; +} + +export const GridRowsContext = createContext(null); + +export function useGridRowsContext() { + return useContext(GridRowsContext); +} + +export function useGridRows() { + return useGridRowsContext()?.rowOrders; +} + +export function useGridRowOrders() { + const rows = useContext(DatabaseContext)?.rowDocMap; + const [rowOrders, setRowOrders] = useState(); + const view = useDatabaseView(); + const sorts = view?.get(YjsDatabaseKey.sorts); + const fields = useDatabaseFields(); + const filters = view?.get(YjsDatabaseKey.filters); + + useEffect(() => { + const onConditionsChange = () => { + const originalRowOrders = view?.get(YjsDatabaseKey.row_orders).toJSON(); + + if (!originalRowOrders || !rows) return; + + console.log('sort or filter changed'); + if (sorts?.length === 0 && filters?.length === 0) { + setRowOrders(originalRowOrders); + return; + } + + let rowOrders: Row[] | undefined; + + if (sorts?.length) { + rowOrders = sortBy(originalRowOrders, sorts, fields, rows); + } + + if (filters?.length) { + rowOrders = filterBy(rowOrders ?? originalRowOrders, filters, fields, rows); + } + + if (rowOrders) { + setRowOrders(rowOrders); + } else { + setRowOrders(originalRowOrders); + } + }; + + const debounceConditionsChange = debounce(onConditionsChange, 200); + + onConditionsChange(); + sorts?.observeDeep(debounceConditionsChange); + filters?.observeDeep(debounceConditionsChange); + fields?.observeDeep(debounceConditionsChange); + rows?.observeDeep(debounceConditionsChange); + + return () => { + sorts?.unobserveDeep(debounceConditionsChange); + filters?.unobserveDeep(debounceConditionsChange); + fields?.unobserveDeep(debounceConditionsChange); + rows?.observeDeep(debounceConditionsChange); + }; + }, [fields, rows, sorts, filters, view]); + + return rowOrders; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/database.type.ts b/frontend/appflowy_web_app/src/application/database-yjs/database.type.ts new file mode 100644 index 0000000000..f5d4aeac61 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/database.type.ts @@ -0,0 +1,51 @@ +import { FieldId } from '@/application/collab.type'; + +export enum FieldVisibility { + AlwaysShown = 0, + HideWhenEmpty = 1, + AlwaysHidden = 2, +} + +export enum FieldType { + RichText = 0, + Number = 1, + DateTime = 2, + SingleSelect = 3, + MultiSelect = 4, + Checkbox = 5, + URL = 6, + Checklist = 7, + LastEditedTime = 8, + CreatedTime = 9, + Relation = 10, +} + +export enum CalculationType { + Average = 0, + Max = 1, + Median = 2, + Min = 3, + Sum = 4, + Count = 5, + CountEmpty = 6, + CountNonEmpty = 7, +} + +export enum SortCondition { + Ascending = 0, + Descending = 1, +} + +export enum FilterType { + Data = 0, + And = 1, + Or = 2, +} + +export interface Filter { + fieldId: FieldId; + filterType: FilterType; + condition: number; + id: string; + content: string; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/checkbox/checkbox.type.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/checkbox/checkbox.type.ts new file mode 100644 index 0000000000..b9da4341f6 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/checkbox/checkbox.type.ts @@ -0,0 +1,10 @@ +import { Filter } from '@/application/database-yjs'; + +export enum CheckboxFilterCondition { + IsChecked = 0, + IsUnChecked = 1, +} + +export interface CheckboxFilter extends Filter { + condition: CheckboxFilterCondition; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/checkbox/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/checkbox/index.ts new file mode 100644 index 0000000000..9ccd409dc8 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/checkbox/index.ts @@ -0,0 +1 @@ +export * from './checkbox.type'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/checklist.type.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/checklist.type.ts new file mode 100644 index 0000000000..2b504ded8a --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/checklist.type.ts @@ -0,0 +1,10 @@ +import { Filter } from '@/application/database-yjs'; + +export enum ChecklistFilterCondition { + IsComplete = 0, + IsIncomplete = 1, +} + +export interface ChecklistFilter extends Filter { + condition: ChecklistFilterCondition; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/index.ts new file mode 100644 index 0000000000..15d37f912b --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/index.ts @@ -0,0 +1,2 @@ +export * from './checklist.type'; +export * from './parse'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/parse.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/parse.ts new file mode 100644 index 0000000000..6dd14c71e0 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/parse.ts @@ -0,0 +1,22 @@ +import { SelectOption } from '../select-option'; + +export interface ChecklistCellData { + selectedOptionIds?: string[]; + options?: SelectOption[]; + percentage: number; +} + +export function parseChecklistData(data: string): ChecklistCellData | null { + try { + const { options, selected_option_ids } = JSON.parse(data); + const percentage = (selected_option_ids.length / options.length) * 100; + + return { + percentage, + options, + selectedOptionIds: selected_option_ids, + }; + } catch (e) { + return null; + } +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/date/date.type.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/date/date.type.ts new file mode 100644 index 0000000000..0db15f21eb --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/date/date.type.ts @@ -0,0 +1,32 @@ +import { Filter } from '@/application/database-yjs'; + +export enum TimeFormat { + TwelveHour = 0, + TwentyFourHour = 1, +} + +export enum DateFormat { + Local = 0, + US = 1, + ISO = 2, + Friendly = 3, + DayMonthYear = 4, +} + +export enum DateFilterCondition { + DateIs = 0, + DateBefore = 1, + DateAfter = 2, + DateOnOrBefore = 3, + DateOnOrAfter = 4, + DateWithIn = 5, + DateIsEmpty = 6, + DateIsNotEmpty = 7, +} + +export interface DateFilter extends Filter { + condition: DateFilterCondition; + start?: number; + end?: number; + timestamp?: number; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/date/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/date/index.ts new file mode 100644 index 0000000000..106279c949 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/date/index.ts @@ -0,0 +1,2 @@ +export * from './date.type'; +export * from './utils'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/date/utils.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/date/utils.ts new file mode 100644 index 0000000000..985402768b --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/date/utils.ts @@ -0,0 +1,29 @@ +import { TimeFormat, DateFormat } from '@/application/database-yjs'; + +export function getTimeFormat(timeFormat?: TimeFormat) { + switch (timeFormat) { + case TimeFormat.TwelveHour: + return 'h:mm A'; + case TimeFormat.TwentyFourHour: + return 'HH:mm'; + default: + return 'HH:mm'; + } +} + +export function getDateFormat(dateFormat?: DateFormat) { + switch (dateFormat) { + case DateFormat.Friendly: + return 'MMM DD, YYYY'; + case DateFormat.ISO: + return 'YYYY-MM-DD'; + case DateFormat.US: + return 'YYYY/MM/DD'; + case DateFormat.Local: + return 'MM/DD/YYYY'; + case DateFormat.DayMonthYear: + return 'DD/MM/YYYY'; + default: + return 'YYYY-MM-DD'; + } +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/index.ts new file mode 100644 index 0000000000..5505f0e4ed --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/index.ts @@ -0,0 +1,8 @@ +export * from './type_option'; +export * from './date'; +export * from './number'; +export * from './select-option'; +export * from './text'; +export * from './checkbox'; +export * from './checklist'; +export * from './relation'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/number/__tests__/format.test.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/__tests__/format.test.ts new file mode 100644 index 0000000000..e165752348 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/__tests__/format.test.ts @@ -0,0 +1,628 @@ +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/frontend/appflowy_web_app/src/application/database-yjs/fields/number/format.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/format.ts new file mode 100644 index 0000000000..589f6ac3ec --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/format.ts @@ -0,0 +1,229 @@ +import { NumberFormat } from './number.type'; + +const commonProps = { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + style: 'currency', + currencyDisplay: 'symbol', + useGrouping: true, +}; + +export const currencyFormaterMap: Record string> = { + [NumberFormat.Num]: (n: number) => + new Intl.NumberFormat('en-US', { + style: 'decimal', + minimumFractionDigits: 0, + maximumFractionDigits: 20, + }).format(n), + [NumberFormat.Percent]: (n: number) => + new Intl.NumberFormat('en-US', { + ...commonProps, + style: 'decimal', + }).format(n) + '%', + [NumberFormat.USD]: (n: number) => + new Intl.NumberFormat('en-US', { + ...commonProps, + currency: 'USD', + }).format(n), + [NumberFormat.CanadianDollar]: (n: number) => + new Intl.NumberFormat('en-CA', { + ...commonProps, + currency: 'CAD', + }) + .format(n) + .replace('$', 'CA$'), + [NumberFormat.EUR]: (n: number) => + new Intl.NumberFormat('en-IE', { + ...commonProps, + currency: 'EUR', + }).format(n), + [NumberFormat.Pound]: (n: number) => + new Intl.NumberFormat('en-GB', { + ...commonProps, + currency: 'GBP', + }).format(n), + [NumberFormat.Yen]: (n: number) => + new Intl.NumberFormat('ja-JP', { + ...commonProps, + currency: 'JPY', + }).format(n), + [NumberFormat.Ruble]: (n: number) => + new Intl.NumberFormat('ru-RU', { + ...commonProps, + currency: 'RUB', + currencyDisplay: 'code', + }) + .format(n) + .replaceAll(' ', ' '), + [NumberFormat.Rupee]: (n: number) => + new Intl.NumberFormat('hi-IN', { + ...commonProps, + currency: 'INR', + }).format(n), + [NumberFormat.Won]: (n: number) => + new Intl.NumberFormat('ko-KR', { + ...commonProps, + currency: 'KRW', + }).format(n), + [NumberFormat.Yuan]: (n: number) => + new Intl.NumberFormat('zh-CN', { + ...commonProps, + currency: 'CNY', + }) + .format(n) + .replace('¥', 'CN¥'), + [NumberFormat.Real]: (n: number) => + new Intl.NumberFormat('pt-BR', { + ...commonProps, + currency: 'BRL', + }) + .format(n) + .replaceAll(' ', ' '), + [NumberFormat.Lira]: (n: number) => + new Intl.NumberFormat('tr-TR', { + ...commonProps, + currency: 'TRY', + currencyDisplay: 'code', + }) + .format(n) + .replaceAll(' ', ' '), + [NumberFormat.Rupiah]: (n: number) => + new Intl.NumberFormat('id-ID', { + ...commonProps, + currency: 'IDR', + currencyDisplay: 'code', + }) + .format(n) + .replaceAll(' ', ' '), + [NumberFormat.Franc]: (n: number) => + new Intl.NumberFormat('de-CH', { + ...commonProps, + currency: 'CHF', + }) + .format(n) + .replaceAll(' ', ' '), + [NumberFormat.HongKongDollar]: (n: number) => + new Intl.NumberFormat('zh-HK', { + ...commonProps, + currency: 'HKD', + }).format(n), + [NumberFormat.NewZealandDollar]: (n: number) => + new Intl.NumberFormat('en-NZ', { + ...commonProps, + currency: 'NZD', + }) + .format(n) + .replace('$', 'NZ$'), + [NumberFormat.Krona]: (n: number) => + new Intl.NumberFormat('sv-SE', { + ...commonProps, + currency: 'SEK', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.NorwegianKrone]: (n: number) => + new Intl.NumberFormat('nb-NO', { + ...commonProps, + currency: 'NOK', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.MexicanPeso]: (n: number) => + new Intl.NumberFormat('es-MX', { + ...commonProps, + currency: 'MXN', + }) + .format(n) + .replace('$', 'MX$'), + [NumberFormat.Rand]: (n: number) => + new Intl.NumberFormat('en-ZA', { + ...commonProps, + currency: 'ZAR', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.NewTaiwanDollar]: (n: number) => + new Intl.NumberFormat('zh-TW', { + ...commonProps, + currency: 'TWD', + }) + .format(n) + .replace('$', 'NT$'), + [NumberFormat.DanishKrone]: (n: number) => + new Intl.NumberFormat('da-DK', { + ...commonProps, + currency: 'DKK', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.Baht]: (n: number) => + new Intl.NumberFormat('th-TH', { + ...commonProps, + currency: 'THB', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.Forint]: (n: number) => + new Intl.NumberFormat('hu-HU', { + ...commonProps, + currency: 'HUF', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.Koruna]: (n: number) => + new Intl.NumberFormat('cs-CZ', { + ...commonProps, + currency: 'CZK', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.Shekel]: (n: number) => + new Intl.NumberFormat('he-IL', { + ...commonProps, + currency: 'ILS', + }).format(n), + [NumberFormat.ChileanPeso]: (n: number) => + new Intl.NumberFormat('es-CL', { + ...commonProps, + currency: 'CLP', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.PhilippinePeso]: (n: number) => + new Intl.NumberFormat('fil-PH', { + ...commonProps, + currency: 'PHP', + }).format(n), + [NumberFormat.Dirham]: (n: number) => + new Intl.NumberFormat('ar-AE', { + ...commonProps, + currency: 'AED', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.ColombianPeso]: (n: number) => + new Intl.NumberFormat('es-CO', { + ...commonProps, + currency: 'COP', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.Riyal]: (n: number) => + new Intl.NumberFormat('en-US', { + ...commonProps, + currency: 'SAR', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.Ringgit]: (n: number) => + new Intl.NumberFormat('ms-MY', { + ...commonProps, + currency: 'MYR', + }).format(n), + [NumberFormat.Leu]: (n: number) => + new Intl.NumberFormat('ro-RO', { + ...commonProps, + currency: 'RON', + }).format(n), + [NumberFormat.ArgentinePeso]: (n: number) => + new Intl.NumberFormat('es-AR', { + ...commonProps, + currency: 'ARS', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.UruguayanPeso]: (n: number) => + new Intl.NumberFormat('es-UY', { + ...commonProps, + currency: 'UYU', + currencyDisplay: 'code', + }).format(n), +}; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/number/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/index.ts new file mode 100644 index 0000000000..27ca7cd8d8 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/index.ts @@ -0,0 +1,3 @@ +export * from './format'; +export * from './number.type'; +export * from './parse'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/number/number.type.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/number.type.ts new file mode 100644 index 0000000000..9140531325 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/number.type.ts @@ -0,0 +1,56 @@ +import { Filter } from '@/application/database-yjs'; + +export enum NumberFormat { + Num = 0, + USD = 1, + CanadianDollar = 2, + EUR = 4, + Pound = 5, + Yen = 6, + Ruble = 7, + Rupee = 8, + Won = 9, + Yuan = 10, + Real = 11, + Lira = 12, + Rupiah = 13, + Franc = 14, + HongKongDollar = 15, + NewZealandDollar = 16, + Krona = 17, + NorwegianKrone = 18, + MexicanPeso = 19, + Rand = 20, + NewTaiwanDollar = 21, + DanishKrone = 22, + Baht = 23, + Forint = 24, + Koruna = 25, + Shekel = 26, + ChileanPeso = 27, + PhilippinePeso = 28, + Dirham = 29, + ColombianPeso = 30, + Riyal = 31, + Ringgit = 32, + Leu = 33, + ArgentinePeso = 34, + UruguayanPeso = 35, + Percent = 36, +} + +export enum NumberFilterCondition { + Equal = 0, + NotEqual = 1, + GreaterThan = 2, + LessThan = 3, + GreaterThanOrEqualTo = 4, + LessThanOrEqualTo = 5, + NumberIsEmpty = 6, + NumberIsNotEmpty = 7, +} + +export interface NumberFilter extends Filter { + condition: NumberFilterCondition; + content: string; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/number/parse.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/parse.ts new file mode 100644 index 0000000000..9abac198b4 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/parse.ts @@ -0,0 +1,11 @@ +import { YDatabaseField } from '@/application/collab.type'; +import { getTypeOptions } from '../type_option'; +import { NumberFormat } from './number.type'; + +export function parseNumberTypeOptions(field: YDatabaseField) { + const numberTypeOption = getTypeOptions(field)?.toJSON(); + + return { + format: parseInt(numberTypeOption.format) as NumberFormat, + }; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/index.ts new file mode 100644 index 0000000000..4b94064b52 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/index.ts @@ -0,0 +1,2 @@ +export * from './parse'; +export * from './relation.type'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/parse.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/parse.ts new file mode 100644 index 0000000000..c5820576cd --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/parse.ts @@ -0,0 +1,9 @@ +import { YDatabaseField } from '@/application/collab.type'; +import { RelationTypeOption } from './relation.type'; +import { getTypeOptions } from '../type_option'; + +export function parseRelationTypeOption(field: YDatabaseField) { + const relationTypeOption = getTypeOptions(field)?.toJSON(); + + return relationTypeOption as RelationTypeOption; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/relation.type.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/relation.type.ts new file mode 100644 index 0000000000..31021afc38 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/relation.type.ts @@ -0,0 +1,9 @@ +import { Filter } from '@/application/database-yjs'; + +export interface RelationTypeOption { + database_id: string; +} + +export interface RelationFilter extends Filter { + condition: number; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/index.ts new file mode 100644 index 0000000000..a569b2ca47 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/index.ts @@ -0,0 +1,2 @@ +export * from './select_option.type'; +export * from './parse'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/parse.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/parse.ts new file mode 100644 index 0000000000..7840278a34 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/parse.ts @@ -0,0 +1,28 @@ +import { YDatabaseField, YjsDatabaseKey } from '@/application/collab.type'; +import { getTypeOptions } from '../type_option'; +import { SelectTypeOption } from './select_option.type'; + +export function parseSelectOptionTypeOptions(field: YDatabaseField) { + const content = getTypeOptions(field)?.get(YjsDatabaseKey.content); + + if (!content) return null; + + try { + return JSON.parse(content) as SelectTypeOption; + } catch (e) { + return null; + } +} + +export function parseSelectOptionCellData(field: YDatabaseField, data: string) { + const typeOption = parseSelectOptionTypeOptions(field); + const selectedIds = typeof data === 'string' ? data.split(',') : []; + + return selectedIds + .map((id) => { + const option = typeOption?.options?.find((option) => option.id === id); + + return option?.name ?? ''; + }) + .join(', '); +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/select_option.type.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/select_option.type.ts new file mode 100644 index 0000000000..343941d588 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/select_option.type.ts @@ -0,0 +1,38 @@ +import { Filter } from '@/application/database-yjs'; + +export enum SelectOptionColor { + Purple = 'Purple', + Pink = 'Pink', + LightPink = 'LightPink', + Orange = 'Orange', + Yellow = 'Yellow', + Lime = 'Lime', + Green = 'Green', + Aqua = 'Aqua', + Blue = 'Blue', +} + +export enum SelectOptionFilterCondition { + OptionIs = 0, + OptionIsNot = 1, + OptionContains = 2, + OptionDoesNotContain = 3, + OptionIsEmpty = 4, + OptionIsNotEmpty = 5, +} + +export interface SelectOptionFilter extends Filter { + condition: SelectOptionFilterCondition; + optionIds: string[]; +} + +export interface SelectOption { + id: string; + name: string; + color: SelectOptionColor; +} + +export interface SelectTypeOption { + disable_color: boolean; + options: SelectOption[]; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/text/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/text/index.ts new file mode 100644 index 0000000000..7d0a52cd9d --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/text/index.ts @@ -0,0 +1 @@ +export * from './text.type'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/text/text.type.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/text/text.type.ts new file mode 100644 index 0000000000..c2f230c738 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/text/text.type.ts @@ -0,0 +1,17 @@ +import { Filter } from '@/application/database-yjs'; + +export enum TextFilterCondition { + TextIs = 0, + TextIsNot = 1, + TextContains = 2, + TextDoesNotContain = 3, + TextStartsWith = 4, + TextEndsWith = 5, + TextIsEmpty = 6, + TextIsNotEmpty = 7, +} + +export interface TextFilter extends Filter { + condition: TextFilterCondition; + content: string; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/type_option.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/type_option.ts new file mode 100644 index 0000000000..bf9c80706f --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/type_option.ts @@ -0,0 +1,8 @@ +import { YDatabaseField, YjsDatabaseKey } from '@/application/collab.type'; +import { FieldType } from '@/application/database-yjs'; + +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/frontend/appflowy_web_app/src/application/database-yjs/filter.ts b/frontend/appflowy_web_app/src/application/database-yjs/filter.ts new file mode 100644 index 0000000000..73a8663371 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/filter.ts @@ -0,0 +1,223 @@ +import { + YDatabaseFields, + YDatabaseFilter, + YDatabaseFilters, + YDatabaseRow, + YDoc, + YjsDatabaseKey, + YjsEditorKey, +} from '@/application/collab.type'; +import { FieldType } from '@/application/database-yjs/database.type'; +import { + CheckboxFilter, + CheckboxFilterCondition, + ChecklistFilter, + ChecklistFilterCondition, + DateFilter, + NumberFilter, + NumberFilterCondition, + parseChecklistData, + SelectOptionFilter, + SelectOptionFilterCondition, + TextFilter, + TextFilterCondition, +} from '@/application/database-yjs/fields'; +import { Row } from '@/application/database-yjs/selector'; +import Decimal from 'decimal.js'; +import * as Y from 'yjs'; +import { every, filter, some } from 'lodash-es'; + +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); + const content = filter.get(YjsDatabaseKey.content); + const condition = Number(filter.get(YjsDatabaseKey.condition)); + + const value = { + fieldId, + filterType, + condition, + id, + content, + }; + + switch (fieldType) { + case FieldType.URL: + case FieldType.RichText: + return value as TextFilter; + case FieldType.Number: + return value as NumberFilter; + case FieldType.Checklist: + return value as ChecklistFilter; + case FieldType.Checkbox: + return value as CheckboxFilter; + case FieldType.SingleSelect: + case FieldType.MultiSelect: + // eslint-disable-next-line no-case-declarations + const options = content.split(','); + + return { + ...value, + optionIds: options, + } as SelectOptionFilter; + case FieldType.DateTime: + case FieldType.CreatedTime: + case FieldType.LastEditedTime: + return value as DateFilter; + } + + return value; +} + +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: Y.Map) { + const filterArray = filters.toArray(); + const conditions = filterArray.map((filter) => { + return (row: { id: string }) => { + const fieldId = filter.get(YjsDatabaseKey.field_id); + const field = fields.get(fieldId); + const fieldType = Number(field.get(YjsDatabaseKey.type)); + const rowId = row.id; + const rowMeta = rowMetas.get(rowId); + + if (!rowMeta) return false; + const filterValue = parseFilter(fieldType, filter); + const meta = rowMeta.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database_row) as YDatabaseRow; + + if (!meta) return false; + + const cells = meta.get(YjsDatabaseKey.cells); + const cell = cells.get(fieldId); + + if (!cell) return false; + const { condition, content } = filterValue; + + switch (fieldType) { + case FieldType.URL: + case FieldType.RichText: + return textFilterCheck(cell.get(YjsDatabaseKey.data) as string, content, condition); + case FieldType.Number: + return numberFilterCheck(cell.get(YjsDatabaseKey.data) as string, content, condition); + case FieldType.Checkbox: + return checkboxFilterCheck(cell.get(YjsDatabaseKey.data) as string, condition); + case FieldType.SingleSelect: + case FieldType.MultiSelect: + return selectOptionFilterCheck(cell.get(YjsDatabaseKey.data) as string, content, condition); + case FieldType.Checklist: + return checklistFilterCheck(cell.get(YjsDatabaseKey.data) as string, content, condition); + default: + return true; + } + }; + }); + const predicate = createPredicate(conditions); + + return filter(rows, predicate); +} + +export function textFilterCheck(data: string, content: string, condition: TextFilterCondition) { + switch (condition) { + case TextFilterCondition.TextContains: + return data.includes(content); + case TextFilterCondition.TextDoesNotContain: + return !data.includes(content); + case TextFilterCondition.TextIs: + return data === content; + case TextFilterCondition.TextIsNot: + return data !== content; + case TextFilterCondition.TextIsEmpty: + return data === ''; + case TextFilterCondition.TextIsNotEmpty: + return data !== ''; + default: + return false; + } +} + +export function numberFilterCheck(data: string, content: string, condition: number) { + const decimal = new Decimal(data).toNumber(); + const filterDecimal = new Decimal(content).toNumber(); + + switch (condition) { + case NumberFilterCondition.Equal: + return decimal === filterDecimal; + case NumberFilterCondition.NotEqual: + return decimal !== filterDecimal; + case NumberFilterCondition.GreaterThan: + return decimal > filterDecimal; + case NumberFilterCondition.GreaterThanOrEqualTo: + return decimal >= filterDecimal; + case NumberFilterCondition.LessThan: + return decimal < filterDecimal; + case NumberFilterCondition.LessThanOrEqualTo: + return decimal <= filterDecimal; + case NumberFilterCondition.NumberIsEmpty: + return data === ''; + case NumberFilterCondition.NumberIsNotEmpty: + return data !== ''; + default: + return false; + } +} + +export function checkboxFilterCheck(data: string, condition: number) { + switch (condition) { + case CheckboxFilterCondition.IsChecked: + return data === 'Yes'; + case CheckboxFilterCondition.IsUnChecked: + return data !== 'Yes'; + default: + return false; + } +} + +export function checklistFilterCheck(data: string, content: string, condition: number) { + const percentage = parseChecklistData(data)?.percentage ?? 0; + + if (condition === ChecklistFilterCondition.IsComplete) { + return percentage === 100; + } + + return percentage !== 100; +} + +export function selectOptionFilterCheck(data: string, content: string, condition: number) { + const selectedOptionIds = data.split(','); + const filterOptionIds = content.split(','); + + 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: + return some(filterOptionIds, (option) => selectedOptionIds.includes(option)); + + // Ensure at least one of the filterOptionIds is not included in selectedOptionIds + case SelectOptionFilterCondition.OptionDoesNotContain: + return some(filterOptionIds, (option) => !selectedOptionIds.includes(option)); + + // Ensure selectedOptionIds is empty + case SelectOptionFilterCondition.OptionIsEmpty: + return selectedOptionIds.length === 0; + + // Ensure selectedOptionIds is not empty + case SelectOptionFilterCondition.OptionIsNotEmpty: + return selectedOptionIds.length !== 0; + + // Default case, if no conditions match + default: + return false; + } +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/index.ts new file mode 100644 index 0000000000..708ae080d2 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/index.ts @@ -0,0 +1,8 @@ +export * from './context'; +export * from './fields'; +export * from './context'; +export * from './selector'; +export * from './database.type'; +export * from './const'; +export * from './filter'; +export * from './sort'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/selector.ts b/frontend/appflowy_web_app/src/application/database-yjs/selector.ts new file mode 100644 index 0000000000..c3222fdf65 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/selector.ts @@ -0,0 +1,227 @@ +import { FieldId, SortId, YDatabaseField, YjsDatabaseKey } from '@/application/collab.type'; +import { MIN_COLUMN_WIDTH } from '@/application/database-yjs/const'; +import { useDatabase, useGridRows, useViewId } from '@/application/database-yjs/context'; +import { parseFilter } from '@/application/database-yjs/filter'; +import { FieldType, FieldVisibility, Filter, SortCondition } from './database.type'; +import { useEffect, useMemo, useState } from 'react'; + +export interface Column { + fieldId: string; + width: number; + visibility: FieldVisibility; + wrap?: boolean; +} + +export interface Row { + id: string; + height: number; +} + +const defaultVisible = [FieldVisibility.AlwaysShown, FieldVisibility.HideWhenEmpty]; + +export function useGridColumnsSelector(viewId: string, visibilitys: FieldVisibility[] = defaultVisible) { + const database = useDatabase(); + const [columns, setColumns] = useState([]); + + useEffect(() => { + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + 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 []; + const fieldIds = fieldsOrder.toJSON().map((item) => item.id) as string[]; + + return fieldIds + .map((fieldId) => { + const setting = fieldSettings.get(fieldId); + + return { + fieldId, + width: parseInt(setting?.get(YjsDatabaseKey.width)) || MIN_COLUMN_WIDTH, + visibility: parseInt(setting?.get(YjsDatabaseKey.visibility)) as FieldVisibility, + wrap: setting?.get(YjsDatabaseKey.wrap), + }; + }) + .filter((column) => visibilitys.includes(column.visibility)); + }; + + const observerEvent = () => setColumns(getColumns()); + + setColumns(getColumns()); + + fieldsOrder?.observe(observerEvent); + fieldSettings?.observe(observerEvent); + + return () => { + fieldsOrder?.unobserve(observerEvent); + fieldSettings?.unobserve(observerEvent); + }; + }, [database, viewId, visibilitys]); + + return columns; +} + +export function useGridRowsSelector() { + const rowOrders = useGridRows(); + + return useMemo(() => rowOrders ?? [], [rowOrders]); +} + +export function useFieldSelector(fieldId: string) { + const database = useDatabase(); + const [field, setField] = useState(null); + const [clock, setClock] = useState(0); + + useEffect(() => { + if (!database) return; + + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); + + setField(field || null); + const observerEvent = () => setClock((prev) => prev + 1); + + field.observe(observerEvent); + + return () => { + field.unobserve(observerEvent); + }; + }, [database, fieldId]); + + return { + field, + clock, + }; +} + +export function useFiltersSelector() { + const database = useDatabase(); + const viewId = useViewId(); + const [filters, setFilters] = useState([]); + + useEffect(() => { + if (!viewId) return; + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + const filterOrders = view?.get(YjsDatabaseKey.filters); + + if (!filterOrders) return; + + const getFilters = () => { + return filterOrders.toJSON().map((item) => item.id); + }; + + const observerEvent = () => setFilters(getFilters()); + + setFilters(getFilters()); + + filterOrders.observe(observerEvent); + + return () => { + filterOrders.unobserve(observerEvent); + }; + }, [database, viewId]); + + return filters; +} + +export function useFilterSelector(filterId: string) { + const database = useDatabase(); + const viewId = useViewId(); + const fields = database?.get(YjsDatabaseKey.fields); + const [filterValue, setFilterValue] = useState(null); + + useEffect(() => { + if (!viewId) return; + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + const filter = view + ?.get(YjsDatabaseKey.filters) + .toArray() + .find((filter) => filter.get(YjsDatabaseKey.id) === filterId); + const field = fields?.get(filter?.get(YjsDatabaseKey.field_id) as FieldId); + + const observerEvent = () => { + if (!filter || !field) return; + const fieldType = Number(field.get(YjsDatabaseKey.type)) as FieldType; + + setFilterValue(parseFilter(fieldType, filter)); + }; + + observerEvent(); + field?.observe(observerEvent); + filter?.observe(observerEvent); + return () => { + field?.unobserve(observerEvent); + filter?.unobserve(observerEvent); + }; + }, [fields, viewId, filterId, database]); + return filterValue; +} + +export function useSortsSelector() { + const database = useDatabase(); + const viewId = useViewId(); + const [sorts, setSorts] = useState([]); + + useEffect(() => { + if (!viewId) return; + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + const sortOrders = view?.get(YjsDatabaseKey.sorts); + + if (!sortOrders) return; + + const getSorts = () => { + return sortOrders.toJSON().map((item) => item.id); + }; + + const observerEvent = () => setSorts(getSorts()); + + setSorts(getSorts()); + + sortOrders.observe(observerEvent); + + return () => { + sortOrders.unobserve(observerEvent); + }; + }, [database, viewId]); + + return sorts; +} + +export interface Sort { + fieldId: FieldId; + condition: SortCondition; + id: SortId; +} + +export function useSortSelector(sortId: SortId) { + const database = useDatabase(); + const viewId = useViewId(); + const [sortValue, setSortValue] = useState(null); + const views = database?.get(YjsDatabaseKey.views); + + useEffect(() => { + if (!viewId) return; + const view = views?.get(viewId); + const sort = view + ?.get(YjsDatabaseKey.sorts) + .toArray() + .find((sort) => sort.get(YjsDatabaseKey.id) === sortId); + + const observerEvent = () => { + setSortValue({ + fieldId: sort?.get(YjsDatabaseKey.field_id) as FieldId, + condition: Number(sort?.get(YjsDatabaseKey.condition)), + id: sort?.get(YjsDatabaseKey.id) as SortId, + }); + }; + + observerEvent(); + sort?.observe(observerEvent); + + return () => { + sort?.unobserve(observerEvent); + }; + }, [viewId, sortId, views]); + + return sortValue; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/sort.ts b/frontend/appflowy_web_app/src/application/database-yjs/sort.ts new file mode 100644 index 0000000000..355d4b4ad9 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/sort.ts @@ -0,0 +1,79 @@ +import { + YDatabaseField, + YDatabaseFields, + YDatabaseRow, + YDatabaseSorts, + YDoc, + YjsDatabaseKey, + YjsEditorKey, +} from '@/application/collab.type'; +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/orderBy'; +import * as Y from 'yjs'; + +export function sortBy(rows: Row[], sorts: YDatabaseSorts, fields: YDatabaseFields, rowMetas: Y.Map) { + const sortArray = sorts.toArray(); + const iteratees = sortArray.map((sort) => { + return (row: { id: string }) => { + const fieldId = sort.get(YjsDatabaseKey.field_id); + const field = fields.get(fieldId); + const fieldType = Number(field.get(YjsDatabaseKey.type)); + + const rowId = row.id; + const rowMeta = rowMetas.get(rowId); + + const defaultData = parseCellDataForSort(field, ''); + + if (!rowMeta) return defaultData; + const meta = rowMeta.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database_row) as YDatabaseRow; + + if (!meta) return defaultData; + if (fieldType === FieldType.LastEditedTime) { + return meta.get(YjsDatabaseKey.last_modified); + } + + if (fieldType === FieldType.CreatedTime) { + return meta.get(YjsDatabaseKey.created_at); + } + + const cells = meta.get(YjsDatabaseKey.cells); + const cell = cells.get(fieldId); + + if (!cell) return defaultData; + + 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 orderBy(rows, iteratees, orders); +} + +export function parseCellDataForSort(field: YDatabaseField, data: string | boolean | number | object) { + const fieldType = Number(field.get(YjsDatabaseKey.type)); + + switch (fieldType) { + case FieldType.RichText: + case FieldType.URL: + case FieldType.Number: + return data; + case FieldType.Checkbox: + return data === 'Yes'; + case FieldType.SingleSelect: + case FieldType.MultiSelect: + return parseSelectOptionCellData(field, typeof data === 'string' ? data : ''); + case FieldType.Checklist: + return parseChecklistData(typeof data === 'string' ? data : '')?.percentage ?? 0; + case FieldType.DateTime: + return Number(data); + case FieldType.Relation: + return ''; + } +} diff --git a/frontend/appflowy_web_app/src/application/document.type.ts b/frontend/appflowy_web_app/src/application/document.type.ts deleted file mode 100644 index da559c5bde..0000000000 --- a/frontend/appflowy_web_app/src/application/document.type.ts +++ /dev/null @@ -1,176 +0,0 @@ -import Y from 'yjs'; - -export type BlockId = string; - -export type ExternalId = string; - -export type ChildrenId = string; - -export enum BlockType { - Paragraph = 'paragraph', - Page = 'page', - HeadingBlock = 'heading', - TodoListBlock = 'todo_list', - BulletedListBlock = 'bulleted_list', - NumberedListBlock = 'numbered_list', - ToggleListBlock = 'toggle_list', - CodeBlock = 'code', - EquationBlock = 'math_equation', - QuoteBlock = 'quote', - CalloutBlock = 'callout', - DividerBlock = 'divider', - ImageBlock = 'image', - GridBlock = 'grid', - OutlineBlock = 'outline', - TableBlock = 'table', - TableCell = 'table/cell', -} - -export enum InlineBlockType { - Formula = 'formula', - Mention = 'mention', -} - -export enum AlignType { - Left = 'left', - Center = 'center', - Right = 'right', -} - -export interface BlockData { - bg_color?: string; - font_color?: string; - align?: AlignType; -} - -export interface HeadingBlockData extends BlockData { - level: number; -} - -export interface NumberedListBlockData extends BlockData { - number: number; -} - -export interface TodoListBlockData extends BlockData { - checked: boolean; -} - -export interface ToggleListBlockData extends BlockData { - collapsed: boolean; -} - -export interface CodeBlockData extends BlockData { - language: string; -} - -export interface CalloutBlockData extends BlockData { - icon: string; -} - -export interface MathEquationBlockData extends BlockData { - formula?: string; -} - -export enum ImageType { - Local = 0, - Internal = 1, - External = 2, -} - -export interface ImageBlockData extends BlockData { - url?: string; - width?: number; - align?: AlignType; - image_type?: ImageType; - height?: number; -} - -export interface OutlineBlockData extends BlockData { - depth?: number; -} - -export interface TableBlockData extends BlockData { - colDefaultWidth: number; - colMinimumWidth: number; - colsHeight: number; - colsLen: number; - rowDefaultHeight: number; - rowsLen: number; -} - -export interface TableCellBlockData extends BlockData { - colPosition: number; - height: number; - rowPosition: number; - width: number; -} - -export enum MentionType { - PageRef = 'page', - Date = 'date', -} - -export interface Mention { - // inline page ref id - page_id?: string; - // reminder date ref id - date?: string; - - type: MentionType; -} - -export enum YjsEditorKey { - data_section = 'data', - document = 'document', - database = 'database', - workspace_database = 'databases', - folder = 'folder', - // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values - database_row = 'data', - user_awareness = 'user_awareness', - blocks = 'blocks', - page_id = 'page_id', - meta = 'meta', - children_map = 'children_map', - text_map = 'text_map', - text = 'text', - delta = 'delta', - - block_id = 'id', - block_type = 'ty', - // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values - block_data = 'data', - block_parent = 'parent', - block_children = 'children', - block_external_id = 'external_id', - block_external_type = 'external_type', -} - -export interface YDoc extends Y.Doc { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - get(key: YjsEditorKey.data_section | string): YSharedRoot | any; -} - -export interface YSharedRoot extends Y.Map { - get(key: YjsEditorKey.document): YDocument; -} - -export interface YDocument extends Y.Map { - get(key: YjsEditorKey.blocks | YjsEditorKey.page_id | YjsEditorKey.meta): YBlocks | YMeta | string; -} - -export interface YBlocks extends Y.Map { - get(key: BlockId): Y.Map; -} - -export interface YMeta extends Y.Map { - get(key: YjsEditorKey.children_map | YjsEditorKey.text_map): YChildrenMap | YTextMap; -} - -export interface YChildrenMap extends Y.Map { - get(key: ChildrenId): Y.Array; -} - -export interface YTextMap extends Y.Map { - get(key: ExternalId): Y.Text; -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/database.service.ts b/frontend/appflowy_web_app/src/application/services/js-services/database.service.ts new file mode 100644 index 0000000000..a1bfcdbf21 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/database.service.ts @@ -0,0 +1,170 @@ +import { CollabOrigin, CollabType, YDatabase, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type'; +import { + batchCollabs, + getCollabStorage, + getCollabStorageWithAPICall, + getUserWorkspace, +} from '@/application/services/js-services/storage'; +import { DatabaseService } from '@/application/services/services.type'; +import * as Y from 'yjs'; + +export class JSDatabaseService implements DatabaseService { + private loadedDatabaseId: Set = new Set(); + + constructor() { + // + } + + async getDatabase( + workspaceId: string, + databaseId: string + ): Promise<{ + databaseDoc: YDoc; + rows: Y.Map; + }> { + const rootRowsDoc = new Y.Doc(); + const rowsFolder = rootRowsDoc.getMap(); + const isLoaded = this.loadedDatabaseId.has(databaseId); + let databaseDoc: YDoc | undefined = undefined; + + if (isLoaded) { + databaseDoc = (await getCollabStorage(databaseId, CollabType.Database)).doc; + } else { + databaseDoc = await getCollabStorageWithAPICall(workspaceId, databaseId, CollabType.Database); + } + + const database = databaseDoc.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database) as YDatabase; + const viewId = database.get(YjsDatabaseKey.metas)?.get(YjsDatabaseKey.iid)?.toString(); + const rowOrders = database.get(YjsDatabaseKey.views)?.get(viewId)?.get(YjsDatabaseKey.row_orders); + const rowIds = rowOrders.toJSON() as { + id: string; + }[]; + + if (!rowIds) { + throw new Error('Database rows not found'); + } + + if (isLoaded) { + for (const row of rowIds) { + const { doc } = await getCollabStorage(row.id, CollabType.DatabaseRow); + + rowsFolder.set(row.id, doc); + } + } else { + const rows = await this.loadDatabaseRows( + workspaceId, + rowIds.map((item) => item.id) + ); + + rows.forEach((row, id) => { + rowsFolder.set(id, row); + }); + } + + this.loadedDatabaseId.add(databaseId); + + return { + databaseDoc, + rows: rowsFolder as Y.Map, + }; + } + + async openDatabase( + workspaceId: string, + viewId: string + ): Promise<{ + databaseDoc: YDoc; + rows: Y.Map; + }> { + const userWorkspace = await getUserWorkspace(); + + if (!userWorkspace) { + throw new Error('User workspace not found'); + } + + const workspaceDatabaseId = userWorkspace.workspaces.find( + (workspace) => workspace.id === workspaceId + )?.workspaceDatabaseId; + + if (!workspaceDatabaseId) { + throw new Error('Workspace database not found'); + } + + const workspaceDatabase = await getCollabStorageWithAPICall( + workspaceId, + workspaceDatabaseId, + CollabType.WorkspaceDatabase + ); + + const databases = workspaceDatabase + .getMap(YjsEditorKey.data_section) + .get(YjsEditorKey.workspace_database) + .toJSON() as { + views: string[]; + database_id: string; + }[]; + + const databaseMeta = databases.find((item) => { + return item.views.some((databaseViewId: string) => databaseViewId === viewId); + }); + + if (!databaseMeta) { + throw new Error('Database not found'); + } + + const { databaseDoc, rows } = await this.getDatabase(workspaceId, databaseMeta.database_id); + const database = databaseDoc.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database) as YDatabase; + const rowOrders = database.get(YjsDatabaseKey.views)?.get(viewId)?.get(YjsDatabaseKey.row_orders); + + // Update rows if new rows are added + rowOrders?.observe((event) => { + if (event.changes.added.size > 0) { + const rowIds = rowOrders.toJSON() as { + id: string; + }[]; + + console.log('Update rows', rowIds); + void this.loadDatabaseRows( + workspaceId, + rowIds.map((item) => item.id) + ).then((newRows) => { + newRows.forEach((row, id) => { + rows.set(id, row); + }); + }); + } + }); + const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => { + if (origin === CollabOrigin.LocalSync) { + // Send the update to the server + console.log('update', update); + } + }; + + databaseDoc.on('update', handleUpdate); + + return { + databaseDoc, + rows, + }; + } + + async loadDatabaseRows(workspaceId: string, rowIds: string[]) { + const rows = new Map(); + + try { + await batchCollabs( + workspaceId, + rowIds.map((id) => ({ + object_id: id, + collab_type: CollabType.DatabaseRow, + })), + (id, rowDoc) => rows.set(id, rowDoc) + ); + } catch (e) { + console.error(e); + } + + return rows; + } +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/db/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/db/index.ts index ebe8870c15..bf5f0c7aa1 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/db/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/db/index.ts @@ -1,41 +1,8 @@ import { YDoc } from '@/application/collab.type'; -import { getAuthInfo } from '@/application/services/js-services/storage'; -import * as Y from 'yjs'; -import { IndexeddbPersistence } from 'y-indexeddb'; import { databasePrefix } from '@/application/constants'; -import BaseDexie from 'dexie'; -import { usersSchema, UsersTable } from './tables/users'; - -const version = 1; - -type DexieTables = UsersTable; -export type Dexie = BaseDexie & T; - -let db: Dexie | undefined; - -export function getDB() { - const authInfo = getAuthInfo(); - - if (!db && authInfo?.uuid) { - return openDB(authInfo?.uuid); - } - - return db; -} - -export function openDB(uuid: string) { - const dbName = `${databasePrefix}_${uuid}`; - - if (db && db.name === dbName) { - return db; - } - - db = new BaseDexie(dbName) as Dexie; - const schema = Object.assign({}, usersSchema); - - db.version(version).stores(schema); - return db; -} +import { getAuthInfo } from '@/application/services/js-services/storage'; +import { IndexeddbPersistence } from 'y-indexeddb'; +import * as Y from 'yjs'; /** * Open the collaboration database, and return a function to close it @@ -66,3 +33,10 @@ export async function deleteCollabDB(docName: string) { await provider.destroy(); } + +export function getDBName(id: string, type: string) { + const { uuid } = getAuthInfo() || {}; + + if (!uuid) throw new Error('No user found'); + return `${uuid}_${type}_${id}`; +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/db/tables/users.ts b/frontend/appflowy_web_app/src/application/services/js-services/db/tables/users.ts deleted file mode 100644 index 1da8f20b0c..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/db/tables/users.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Table } from 'dexie'; -import { UserProfile } from '@/application/user.type'; - -export type UsersTable = { - users: Table; -}; - -export const usersSchema = { - users: 'uuid, uid, email, name, workspaceId, iconUrl', -}; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/services/js-services/document.service.ts b/frontend/appflowy_web_app/src/application/services/js-services/document.service.ts index 1af92df8a0..e93809449d 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/document.service.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/document.service.ts @@ -1,41 +1,20 @@ import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type'; -import { getDocumentStorage } from '@/application/services/js-services/storage/document'; +import { getCollabStorageWithAPICall } from '@/application/services/js-services/storage'; import { DocumentService } from '@/application/services/services.type'; -import { APIService } from 'src/application/services/js-services/wasm'; -import { applyDocument } from 'src/application/ydoc/apply'; export class JSDocumentService implements DocumentService { constructor() { // } - fetchDocument(workspaceId: string, docId: string) { - return APIService.getCollab(workspaceId, docId, CollabType.Document); - } - async openDocument(workspaceId: string, docId: string): Promise { - const { doc, localExist } = await getDocumentStorage(docId); - const asyncApply = async () => { - const res = await this.fetchDocument(workspaceId, docId); - - applyDocument(doc, res.state); - }; - - // If the document exists locally, apply the state asynchronously, - // otherwise, apply the state synchronously - if (localExist) { - void asyncApply(); - } else { - await asyncApply(); - } + const doc = await getCollabStorageWithAPICall(workspaceId, docId, CollabType.Document); const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => { - if (origin === CollabOrigin.Remote) { - return; + if (origin === CollabOrigin.LocalSync) { + // Send the update to the server + console.log('update', update); } - - // Send the update to the server - console.log('update', update); }; doc.on('update', handleUpdate); diff --git a/frontend/appflowy_web_app/src/application/services/js-services/folder.service.ts b/frontend/appflowy_web_app/src/application/services/js-services/folder.service.ts index 796cd078d6..c475cfa935 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/folder.service.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/folder.service.ts @@ -1,41 +1,19 @@ import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type'; -import { getFolderStorage } from '@/application/services/js-services/storage/folder'; +import { getCollabStorageWithAPICall } from '@/application/services/js-services/storage'; import { FolderService } from '@/application/services/services.type'; -import { APIService } from 'src/application/services/js-services/wasm'; -import { applyDocument } from 'src/application/ydoc/apply'; export class JSFolderService implements FolderService { constructor() { // } - fetchFolder(workspaceId: string) { - return APIService.getCollab(workspaceId, workspaceId, CollabType.Folder); - } - async openWorkspace(workspaceId: string): Promise { - const { doc, localExist } = await getFolderStorage(workspaceId); - const asyncApply = async () => { - const res = await this.fetchFolder(workspaceId); - - applyDocument(doc, res.state); - }; - - // If the document exists locally, apply the state asynchronously, - // otherwise, apply the state synchronously - if (localExist) { - void asyncApply(); - } else { - await asyncApply(); - } - + const doc = await getCollabStorageWithAPICall(workspaceId, workspaceId, CollabType.Folder); const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => { - if (origin === CollabOrigin.Remote) { - return; + if (origin === CollabOrigin.LocalSync) { + // Send the update to the server + console.log('update', update); } - - // Send the update to the server - console.log('update', update); }; doc.on('update', handleUpdate); diff --git a/frontend/appflowy_web_app/src/application/services/js-services/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/index.ts index 3410c8d27e..d31b7f117a 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/index.ts @@ -1,7 +1,9 @@ +import { JSDatabaseService } from '@/application/services/js-services/database.service'; import { AFService, AFServiceConfig, AuthService, + DatabaseService, DocumentService, FolderService, UserService, @@ -22,6 +24,8 @@ export class AFClientService implements AFService { folderService: FolderService; + databaseService: DatabaseService; + private deviceId: string = nanoid(8); private clientId: string = 'web'; @@ -45,5 +49,6 @@ export class AFClientService implements AFService { this.userService = new JSUserService(); this.documentService = new JSDocumentService(); this.folderService = new JSFolderService(); + this.databaseService = new JSDatabaseService(); } } diff --git a/frontend/appflowy_web_app/src/application/services/js-services/storage/auth.ts b/frontend/appflowy_web_app/src/application/services/js-services/storage/auth.ts index bb19f590bc..dd8d3d1d99 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/storage/auth.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/storage/auth.ts @@ -1,11 +1,3 @@ -import { getAuthInfo } from '@/application/services/js-services/storage/token'; -import { openDB } from '@/application/services/js-services/db'; - export async function signInSuccess() { - const authInfo = getAuthInfo(); - - if (authInfo) { - // Open the database - openDB(authInfo.uuid); - } + // Do nothing } diff --git a/frontend/appflowy_web_app/src/application/services/js-services/storage/collab.ts b/frontend/appflowy_web_app/src/application/services/js-services/storage/collab.ts new file mode 100644 index 0000000000..27ce771d74 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/storage/collab.ts @@ -0,0 +1,101 @@ +import { CollabType, YDoc, YjsEditorKey } from '@/application/collab.type'; +import { getDBName, openCollabDB } from '@/application/services/js-services/db'; +import { APIService } from '@/application/services/js-services/wasm'; +import { applyDocument } from '@/application/ydoc/apply'; + +export function fetchCollab(workspaceId: string, id: string, type: CollabType) { + return APIService.getCollab(workspaceId, id, type); +} + +export function batchFetchCollab(workspaceId: string, params: { object_id: string; collab_type: CollabType }[]) { + return APIService.batchGetCollab(workspaceId, params); +} + +function collabTypeToDBType(type: CollabType) { + switch (type) { + case CollabType.Folder: + return 'folder'; + case CollabType.Document: + return 'document'; + case CollabType.Database: + return 'database'; + case CollabType.WorkspaceDatabase: + return 'databases'; + case CollabType.DatabaseRow: + return 'database_row'; + case CollabType.UserAwareness: + return 'user_awareness'; + default: + return ''; + } +} + +export async function getCollabStorage(id: string, type: CollabType) { + const name = getDBName(id, collabTypeToDBType(type)); + + const doc = await openCollabDB(name); + const localExist = doc.share.has(YjsEditorKey.data_section); + + return { + doc, + localExist, + }; +} + +export async function getCollabStorageWithAPICall(workspaceId: string, id: string, type: CollabType) { + const { doc, localExist } = await getCollabStorage(id, type); + const asyncApply = async () => { + const res = await fetchCollab(workspaceId, id, type); + + applyDocument(doc, res.state); + }; + + // If the document exists locally, apply the state asynchronously, + // otherwise, apply the state synchronously + if (localExist) { + void asyncApply(); + } else { + await asyncApply(); + } + + return doc; +} + +export async function batchCollabs( + workspaceId: string, + params: { + object_id: string; + collab_type: CollabType; + }[], + rowCallback?: (id: string, doc: YDoc) => void +) { + console.log('Fetching collab data:', params); + // Create or get Y.Doc from local storage + for (const item of params) { + const { object_id, collab_type } = item; + + const { doc } = await getCollabStorage(object_id, collab_type); + + if (rowCallback) { + rowCallback(object_id, doc); + } + } + + // Async fetch collab data and apply to Y.Doc + void (async () => { + const res = await batchFetchCollab(workspaceId, params); + + for (const id of Object.keys(res)) { + const type = params.find((param) => param.object_id === id)?.collab_type; + const data = res[id]; + + if (type === undefined || !data) { + continue; + } + + const { doc } = await getCollabStorage(id, type); + + applyDocument(doc, data); + } + })(); +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/storage/document.ts b/frontend/appflowy_web_app/src/application/services/js-services/storage/document.ts deleted file mode 100644 index 0c1278d216..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/storage/document.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { YjsEditorKey } from '@/application/collab.type'; -import { openCollabDB } from '@/application/services/js-services/db'; -import { getAuthInfo } from '@/application/services/js-services/storage/token'; - -export async function getDocumentStorage(docId: string) { - const docName = getDocName(docId); - const doc = await openCollabDB(docName); - const localExist = doc.share.has(YjsEditorKey.data_section); - - return { - doc, - localExist, - }; -} - -export function getDocName(docId: string) { - const { uuid } = getAuthInfo() || {}; - - if (!uuid) throw new Error('No user found'); - return `${uuid}_document_${docId}`; -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/storage/folder.ts b/frontend/appflowy_web_app/src/application/services/js-services/storage/folder.ts deleted file mode 100644 index 8d70df8d0a..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/storage/folder.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { YjsEditorKey } from '@/application/collab.type'; -import { openCollabDB } from '@/application/services/js-services/db'; -import { getAuthInfo } from '@/application/services/js-services/storage/token'; - -export async function getFolderStorage(workspaceId: string) { - const docName = getDocName(workspaceId); - const doc = await openCollabDB(docName); - const localExist = doc.share.has(YjsEditorKey.data_section); - - return { - doc, - localExist, - }; -} - -export function getDocName(workspaceId: string) { - const { uuid } = getAuthInfo() || {}; - - if (!uuid) throw new Error('No user found'); - return `${uuid}_folder_${workspaceId}`; -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/storage/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/storage/index.ts index d983c71b07..f0b9cab2d6 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/storage/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/storage/index.ts @@ -1,2 +1,4 @@ -export * from './token'; -export * from './user'; \ No newline at end of file +export * from './token'; +export * from './user'; +export * from './collab'; +export * from './auth'; diff --git a/frontend/appflowy_web_app/src/application/services/js-services/storage/user.ts b/frontend/appflowy_web_app/src/application/services/js-services/storage/user.ts index 0194bb8e0f..db9626ae8e 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/storage/user.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/storage/user.ts @@ -1,18 +1,36 @@ -import { UserProfile } from '@/application/user.type'; -import { getDB } from '@/application/services/js-services/db'; -import { getAuthInfo } from '@/application/services/js-services/storage/token'; +import { UserProfile, UserWorkspace } from '@/application/user.type'; -const primaryKeyName = 'uid'; +const userKey = 'user'; +const workspaceKey = 'workspace'; export async function getSignInUser(): Promise { - const db = getDB(); - const authInfo = getAuthInfo(); + const userStr = localStorage.getItem(userKey); - return db?.users.get(authInfo?.uuid); + try { + return userStr ? JSON.parse(userStr) : undefined; + } catch (e) { + return undefined; + } } export async function setSignInUser(profile: UserProfile) { - const db = getDB(); + const userStr = JSON.stringify(profile); - return db?.users.put(profile, primaryKeyName); + localStorage.setItem(userKey, userStr); +} + +export async function getUserWorkspace(): Promise { + const str = localStorage.getItem(workspaceKey); + + try { + return str ? JSON.parse(str) : undefined; + } catch (e) { + return undefined; + } +} + +export async function setUserWorkspace(workspace: UserWorkspace) { + const str = JSON.stringify(workspace); + + localStorage.setItem(workspaceKey, str); } diff --git a/frontend/appflowy_web_app/src/application/services/js-services/user.service.ts b/frontend/appflowy_web_app/src/application/services/js-services/user.service.ts index 88e8ba996a..c4853f850d 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/user.service.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/user.service.ts @@ -1,7 +1,14 @@ import { UserService } from '@/application/services/services.type'; -import { UserProfile } from '@/application/user.type'; +import { UserProfile, UserWorkspace } from '@/application/user.type'; import { APIService } from 'src/application/services/js-services/wasm'; -import { getAuthInfo, getSignInUser, invalidToken, setSignInUser } from '@/application/services/js-services/storage'; +import { + getAuthInfo, + getSignInUser, + getUserWorkspace, + invalidToken, + setSignInUser, + setUserWorkspace, +} from '@/application/services/js-services/storage'; import { asyncDataDecorator } from '@/application/services/js-services/decorator'; async function getUser() { @@ -22,10 +29,17 @@ export class JSUserService implements UserService { return Promise.reject('Not authenticated'); } + await this.getUserWorkspace(); + return null!; } async checkUser(): Promise { return (await getSignInUser()) !== undefined; } + + @asyncDataDecorator(getUserWorkspace, setUserWorkspace, APIService.getUserWorkspace) + async getUserWorkspace(): Promise { + return null!; + } } diff --git a/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts b/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts index 48a76d1837..f3fecb1215 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts @@ -1,6 +1,6 @@ import { CollabType } from '@/application/collab.type'; import { ClientAPI } from '@appflowyinc/client-api-wasm'; -import { UserProfile } from '@/application/user.type'; +import { UserProfile, UserWorkspace } from '@/application/user.type'; import { AFCloudConfig } from '@/application/services/services.type'; import { invalidToken, readTokenStr, writeToken } from '@/application/services/js-services/storage'; @@ -77,3 +77,45 @@ export async function getCollab(workspaceId: string, object_id: string, collabTy state, }; } + +export async function batchGetCollab( + workspaceId: string, + params: { + object_id: string; + collab_type: CollabType; + }[] +) { + const res = (await client.batch_get_collab( + workspaceId, + params.map((param) => ({ + object_id: param.object_id, + collab_type: Number(param.collab_type) as 0 | 1 | 2 | 3 | 4 | 5, + })) + )) as unknown as Map; + + const result: Record = {}; + + res.forEach((value, key) => { + result[key] = new Uint8Array(value.doc_state); + }); + return result; +} + +export async function getUserWorkspace(): Promise { + const res = await client.get_user_workspace(); + + return { + visitingWorkspaceId: res.visiting_workspace_id, + workspaces: res.workspaces.map((workspace) => ({ + id: workspace.workspace_id, + name: workspace.workspace_name, + icon: workspace.icon, + owner: { + id: Number(workspace.owner_uid), + name: workspace.owner_name, + }, + type: workspace.workspace_type, + workspaceDatabaseId: workspace.database_storage_id, + })), + }; +} diff --git a/frontend/appflowy_web_app/src/application/services/services.type.ts b/frontend/appflowy_web_app/src/application/services/services.type.ts index d7d3ad069c..7e170b683b 100644 --- a/frontend/appflowy_web_app/src/application/services/services.type.ts +++ b/frontend/appflowy_web_app/src/application/services/services.type.ts @@ -1,5 +1,6 @@ import { YDoc } from '@/application/collab.type'; import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/user.type'; +import * as Y from 'yjs'; export interface AFService { getDeviceID: () => string; @@ -8,6 +9,7 @@ export interface AFService { userService: UserService; documentService: DocumentService; folderService: FolderService; + databaseService: DatabaseService; } export interface AFServiceConfig { @@ -32,6 +34,23 @@ export interface DocumentService { openDocument: (workspaceId: string, docId: string) => Promise; } +export interface DatabaseService { + openDatabase: ( + workspaceId: string, + viewId: string + ) => Promise<{ + databaseDoc: YDoc; + rows: Y.Map; + }>; + getDatabase: ( + workspaceId: string, + databaseId: string + ) => Promise<{ + databaseDoc: YDoc; + rows: Y.Map; + }>; +} + export interface UserService { getUserProfile: () => Promise; checkUser: () => Promise; diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/database.service.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/database.service.ts new file mode 100644 index 0000000000..8644914ca7 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/tauri-services/database.service.ts @@ -0,0 +1,29 @@ +import { YDoc } from '@/application/collab.type'; +import { DatabaseService } from '@/application/services/services.type'; +import * as Y from 'yjs'; + +export class TauriDatabaseService implements DatabaseService { + constructor() { + // + } + + async openDatabase( + _workspaceId: string, + _viewId: string + ): Promise<{ + databaseDoc: YDoc; + rows: Y.Map; + }> { + return Promise.reject('Not implemented'); + } + + async getDatabase( + _workspaceId: string, + _databaseId: string + ): Promise<{ + databaseDoc: YDoc; + rows: Y.Map; + }> { + return Promise.reject('Not implemented'); + } +} diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/document.service.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/document.service.ts index 8bcede6523..9ae2987350 100644 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/document.service.ts +++ b/frontend/appflowy_web_app/src/application/services/tauri-services/document.service.ts @@ -1,5 +1,5 @@ import { DocumentService } from '@/application/services/services.type'; -import Y from 'yjs'; +import * as Y from 'yjs'; export class TauriDocumentService implements DocumentService { async openDocument(_id: string): Promise { diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts index 0f162ba36f..8908c002ee 100644 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts @@ -2,11 +2,13 @@ import { AFService, AFServiceConfig, AuthService, + DatabaseService, DocumentService, FolderService, UserService, } from '@/application/services/services.type'; import { TauriAuthService } from '@/application/services/tauri-services/auth.service'; +import { TauriDatabaseService } from '@/application/services/tauri-services/database.service'; import { TauriFolderService } from '@/application/services/tauri-services/folder.service'; import { TauriUserService } from '@/application/services/tauri-services/user.service'; import { TauriDocumentService } from '@/application/services/tauri-services/document.service'; @@ -21,6 +23,8 @@ export class AFClientService implements AFService { folderService: FolderService; + databaseService: DatabaseService; + private deviceId: string = nanoid(8); private clientId: string = 'web'; @@ -41,5 +45,6 @@ export class AFClientService implements AFService { this.userService = new TauriUserService(); this.documentService = new TauriDocumentService(); this.folderService = new TauriFolderService(); + this.databaseService = new TauriDatabaseService(); } } diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts b/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts index 1484813ab1..efa2044622 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts @@ -54,7 +54,11 @@ export const YjsEditor = { }, }; -export function withYjs(editor: T, doc: Y.Doc): T & YjsEditor { +export function withYjs( + editor: T, + doc: Y.Doc, + localOrigin: CollabOrigin = CollabOrigin.Local +): T & YjsEditor { const e = editor as T & YjsEditor; const { apply, onChange } = e; @@ -73,11 +77,9 @@ export function withYjs(editor: T, doc: Y.Doc): T & YjsEditor }; const handleYEvents = (events: Array>, transaction: Transaction) => { - if (transaction.origin === CollabOrigin.Local) { - return; + if (transaction.origin === CollabOrigin.Remote) { + YjsEditor.applyRemoteEvents(e, events, transaction); } - - YjsEditor.applyRemoteEvents(e, events, transaction); }; e.connect = () => { @@ -123,7 +125,7 @@ export function withYjs(editor: T, doc: Y.Doc): T & YjsEditor changes.forEach((change) => { applySlateOp(doc, { children: change.slateContent }, change.op); }); - }, CollabOrigin.Local); + }, localOrigin); }; e.apply = (op) => { diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/utils/applySlateOpts.ts b/frontend/appflowy_web_app/src/application/slate-yjs/utils/applySlateOpts.ts index edb14cfa0a..5a2fd6670c 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/utils/applySlateOpts.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/utils/applySlateOpts.ts @@ -1,6 +1,6 @@ import { Operation, Node } from 'slate'; -import Y from 'yjs'; +import * as Y from 'yjs'; -export function applySlateOp (ydoc: Y.Doc, slateRoot: Node, op: Operation) { +export function applySlateOp(ydoc: Y.Doc, slateRoot: Node, op: Operation) { console.log('applySlateOp', op); -} \ No newline at end of file +} diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/textEvent.ts b/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/textEvent.ts index 3dce8a3d59..dfe5c029e9 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/textEvent.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/textEvent.ts @@ -1,4 +1,4 @@ -import { YSharedRoot } from '@/application/document.type'; +import { YSharedRoot } from '@/application/collab.type'; import * as Y from 'yjs'; import { Editor, Operation } from 'slate'; diff --git a/frontend/appflowy_web_app/src/application/user.type.ts b/frontend/appflowy_web_app/src/application/user.type.ts index be64d574b4..e2c3bcdb43 100644 --- a/frontend/appflowy_web_app/src/application/user.type.ts +++ b/frontend/appflowy_web_app/src/application/user.type.ts @@ -18,6 +18,11 @@ export interface UserProfile { workspaceId?: string; } +export interface UserWorkspace { + visitingWorkspaceId: string; + workspaces: Workspace[]; +} + export interface Workspace { id: string; name: string; @@ -26,6 +31,8 @@ export interface Workspace { id: number; name: string; }; + type: number; + workspaceDatabaseId: string; } export interface SignUpWithEmailPasswordParams { diff --git a/frontend/appflowy_web_app/src/assets/add.svg b/frontend/appflowy_web_app/src/assets/add.svg deleted file mode 100644 index 049be05cec..0000000000 --- a/frontend/appflowy_web_app/src/assets/add.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/align-center.svg b/frontend/appflowy_web_app/src/assets/align-center.svg deleted file mode 100644 index f4f4999514..0000000000 --- a/frontend/appflowy_web_app/src/assets/align-center.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_web_app/src/assets/align-left.svg b/frontend/appflowy_web_app/src/assets/align-left.svg deleted file mode 100644 index 23957285c7..0000000000 --- a/frontend/appflowy_web_app/src/assets/align-left.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_web_app/src/assets/align-right.svg b/frontend/appflowy_web_app/src/assets/align-right.svg deleted file mode 100644 index bca2d14fc7..0000000000 --- a/frontend/appflowy_web_app/src/assets/align-right.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_web_app/src/assets/arrow-left.svg b/frontend/appflowy_web_app/src/assets/arrow-left.svg deleted file mode 100644 index e4ab9068be..0000000000 --- a/frontend/appflowy_web_app/src/assets/arrow-left.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/arrow-right.svg b/frontend/appflowy_web_app/src/assets/arrow-right.svg deleted file mode 100644 index dc40ae52a6..0000000000 --- a/frontend/appflowy_web_app/src/assets/arrow-right.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/board.svg b/frontend/appflowy_web_app/src/assets/board.svg deleted file mode 100644 index 0bb0e3fabe..0000000000 --- a/frontend/appflowy_web_app/src/assets/board.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/bold.svg b/frontend/appflowy_web_app/src/assets/bold.svg deleted file mode 100644 index 878b6329b3..0000000000 --- a/frontend/appflowy_web_app/src/assets/bold.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/clock_alarm.svg b/frontend/appflowy_web_app/src/assets/clock_alarm.svg deleted file mode 100644 index 33a5585ceb..0000000000 --- a/frontend/appflowy_web_app/src/assets/clock_alarm.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/close.svg b/frontend/appflowy_web_app/src/assets/close.svg deleted file mode 100644 index b519b419c0..0000000000 --- a/frontend/appflowy_web_app/src/assets/close.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/copy.svg b/frontend/appflowy_web_app/src/assets/copy.svg deleted file mode 100644 index e21e6cb082..0000000000 --- a/frontend/appflowy_web_app/src/assets/copy.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/dark-logo.svg b/frontend/appflowy_web_app/src/assets/dark-logo.svg deleted file mode 100644 index 80d8c4132e..0000000000 --- a/frontend/appflowy_web_app/src/assets/dark-logo.svg +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/database/checkbox-check.svg b/frontend/appflowy_web_app/src/assets/database/checkbox-check.svg deleted file mode 100644 index d2fc54c4b7..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/checkbox-check.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/checkbox-uncheck.svg b/frontend/appflowy_web_app/src/assets/database/checkbox-uncheck.svg deleted file mode 100644 index 3b3e17dd31..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/checkbox-uncheck.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-attach.svg b/frontend/appflowy_web_app/src/assets/database/field-type-attach.svg deleted file mode 100644 index f00f5c7aa2..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-attach.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-checkbox.svg b/frontend/appflowy_web_app/src/assets/database/field-type-checkbox.svg deleted file mode 100644 index 37f52c47ed..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-checkbox.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-checklist.svg b/frontend/appflowy_web_app/src/assets/database/field-type-checklist.svg deleted file mode 100644 index 3a88d236a1..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-checklist.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-date.svg b/frontend/appflowy_web_app/src/assets/database/field-type-date.svg deleted file mode 100644 index 78243f1e75..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-date.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-last-edited-time.svg b/frontend/appflowy_web_app/src/assets/database/field-type-last-edited-time.svg deleted file mode 100644 index 634af3e361..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-last-edited-time.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-multi-select.svg b/frontend/appflowy_web_app/src/assets/database/field-type-multi-select.svg deleted file mode 100644 index 97a2e9c434..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-multi-select.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-number.svg b/frontend/appflowy_web_app/src/assets/database/field-type-number.svg deleted file mode 100644 index 9d8b98d10d..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-number.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-person.svg b/frontend/appflowy_web_app/src/assets/database/field-type-person.svg deleted file mode 100644 index 2fc04be065..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-person.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-relation.svg b/frontend/appflowy_web_app/src/assets/database/field-type-relation.svg deleted file mode 100644 index f82a41d226..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-relation.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-single-select.svg b/frontend/appflowy_web_app/src/assets/database/field-type-single-select.svg deleted file mode 100644 index 8ccbc9a2e3..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-single-select.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-text.svg b/frontend/appflowy_web_app/src/assets/database/field-type-text.svg deleted file mode 100644 index 7befa5080f..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-text.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-url.svg b/frontend/appflowy_web_app/src/assets/database/field-type-url.svg deleted file mode 100644 index f00f5c7aa2..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-url.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/date.svg b/frontend/appflowy_web_app/src/assets/date.svg deleted file mode 100644 index 78243f1e75..0000000000 --- a/frontend/appflowy_web_app/src/assets/date.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/delete.svg b/frontend/appflowy_web_app/src/assets/delete.svg deleted file mode 100644 index 9e51636798..0000000000 --- a/frontend/appflowy_web_app/src/assets/delete.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/details.svg b/frontend/appflowy_web_app/src/assets/details.svg deleted file mode 100644 index 22c6830916..0000000000 --- a/frontend/appflowy_web_app/src/assets/details.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_web_app/src/assets/document.svg b/frontend/appflowy_web_app/src/assets/document.svg deleted file mode 100644 index b00e1cfb38..0000000000 --- a/frontend/appflowy_web_app/src/assets/document.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/drag.svg b/frontend/appflowy_web_app/src/assets/drag.svg deleted file mode 100644 index 627c959f9f..0000000000 --- a/frontend/appflowy_web_app/src/assets/drag.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/dropdown.svg b/frontend/appflowy_web_app/src/assets/dropdown.svg deleted file mode 100644 index 95e4964b53..0000000000 --- a/frontend/appflowy_web_app/src/assets/dropdown.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/edit.svg b/frontend/appflowy_web_app/src/assets/edit.svg deleted file mode 100644 index ae93287114..0000000000 --- a/frontend/appflowy_web_app/src/assets/edit.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/eye_close.svg b/frontend/appflowy_web_app/src/assets/eye_close.svg deleted file mode 100644 index 116c715ca8..0000000000 --- a/frontend/appflowy_web_app/src/assets/eye_close.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/eye_open.svg b/frontend/appflowy_web_app/src/assets/eye_open.svg deleted file mode 100644 index fa3017c04d..0000000000 --- a/frontend/appflowy_web_app/src/assets/eye_open.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/grid.svg b/frontend/appflowy_web_app/src/assets/grid.svg deleted file mode 100644 index c397af8130..0000000000 --- a/frontend/appflowy_web_app/src/assets/grid.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/h1.svg b/frontend/appflowy_web_app/src/assets/h1.svg deleted file mode 100644 index b33bd52135..0000000000 --- a/frontend/appflowy_web_app/src/assets/h1.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/h2.svg b/frontend/appflowy_web_app/src/assets/h2.svg deleted file mode 100644 index 7449c57391..0000000000 --- a/frontend/appflowy_web_app/src/assets/h2.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/h3.svg b/frontend/appflowy_web_app/src/assets/h3.svg deleted file mode 100644 index 0976945974..0000000000 --- a/frontend/appflowy_web_app/src/assets/h3.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/hide-menu.svg b/frontend/appflowy_web_app/src/assets/hide-menu.svg deleted file mode 100644 index ce88af8ea7..0000000000 --- a/frontend/appflowy_web_app/src/assets/hide-menu.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/hide.svg b/frontend/appflowy_web_app/src/assets/hide.svg deleted file mode 100644 index 22001ef65d..0000000000 --- a/frontend/appflowy_web_app/src/assets/hide.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/image.svg b/frontend/appflowy_web_app/src/assets/image.svg deleted file mode 100644 index 0739605066..0000000000 --- a/frontend/appflowy_web_app/src/assets/image.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_web_app/src/assets/images/default_cover.jpg b/frontend/appflowy_web_app/src/assets/images/default_cover.jpg deleted file mode 100644 index aeaa6a0f29..0000000000 Binary files a/frontend/appflowy_web_app/src/assets/images/default_cover.jpg and /dev/null differ diff --git a/frontend/appflowy_web_app/src/assets/inline-code.svg b/frontend/appflowy_web_app/src/assets/inline-code.svg deleted file mode 100644 index 3585603096..0000000000 --- a/frontend/appflowy_web_app/src/assets/inline-code.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/italic.svg b/frontend/appflowy_web_app/src/assets/italic.svg deleted file mode 100644 index b295c230f0..0000000000 --- a/frontend/appflowy_web_app/src/assets/italic.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/left.svg b/frontend/appflowy_web_app/src/assets/left.svg deleted file mode 100644 index 0f771a3858..0000000000 --- a/frontend/appflowy_web_app/src/assets/left.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_web_app/src/assets/light-logo.svg b/frontend/appflowy_web_app/src/assets/light-logo.svg deleted file mode 100644 index f5cd761ba7..0000000000 --- a/frontend/appflowy_web_app/src/assets/light-logo.svg +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/link.svg b/frontend/appflowy_web_app/src/assets/link.svg deleted file mode 100644 index 5fbcc8d787..0000000000 --- a/frontend/appflowy_web_app/src/assets/link.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/list-dropdown.svg b/frontend/appflowy_web_app/src/assets/list-dropdown.svg deleted file mode 100644 index 4a8424c5f8..0000000000 --- a/frontend/appflowy_web_app/src/assets/list-dropdown.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/list.svg b/frontend/appflowy_web_app/src/assets/list.svg deleted file mode 100644 index 97a2e9c434..0000000000 --- a/frontend/appflowy_web_app/src/assets/list.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/mention.svg b/frontend/appflowy_web_app/src/assets/mention.svg deleted file mode 100644 index b98318132c..0000000000 --- a/frontend/appflowy_web_app/src/assets/mention.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/more.svg b/frontend/appflowy_web_app/src/assets/more.svg deleted file mode 100644 index b191e64a10..0000000000 --- a/frontend/appflowy_web_app/src/assets/more.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/numbers.svg b/frontend/appflowy_web_app/src/assets/numbers.svg deleted file mode 100644 index 9d8b98d10d..0000000000 --- a/frontend/appflowy_web_app/src/assets/numbers.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/open.svg b/frontend/appflowy_web_app/src/assets/open.svg deleted file mode 100644 index b443c8b993..0000000000 --- a/frontend/appflowy_web_app/src/assets/open.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/quote.svg b/frontend/appflowy_web_app/src/assets/quote.svg deleted file mode 100644 index 57839231ff..0000000000 --- a/frontend/appflowy_web_app/src/assets/quote.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/react.svg b/frontend/appflowy_web_app/src/assets/react.svg deleted file mode 100644 index 6c87de9bb3..0000000000 --- a/frontend/appflowy_web_app/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/right.svg b/frontend/appflowy_web_app/src/assets/right.svg deleted file mode 100644 index 7d738f4e69..0000000000 --- a/frontend/appflowy_web_app/src/assets/right.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_web_app/src/assets/search.svg b/frontend/appflowy_web_app/src/assets/search.svg deleted file mode 100644 index a8a92df509..0000000000 --- a/frontend/appflowy_web_app/src/assets/search.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/select-check.svg b/frontend/appflowy_web_app/src/assets/select-check.svg deleted file mode 100644 index 05caec861a..0000000000 --- a/frontend/appflowy_web_app/src/assets/select-check.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/settings.svg b/frontend/appflowy_web_app/src/assets/settings.svg deleted file mode 100644 index 92140a3c23..0000000000 --- a/frontend/appflowy_web_app/src/assets/settings.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/settings/account.svg b/frontend/appflowy_web_app/src/assets/settings/account.svg deleted file mode 100644 index fddfca7575..0000000000 --- a/frontend/appflowy_web_app/src/assets/settings/account.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/settings/check_circle.svg b/frontend/appflowy_web_app/src/assets/settings/check_circle.svg deleted file mode 100644 index c6fa56067b..0000000000 --- a/frontend/appflowy_web_app/src/assets/settings/check_circle.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/settings/dark.png b/frontend/appflowy_web_app/src/assets/settings/dark.png deleted file mode 100644 index 15a2db5eb8..0000000000 Binary files a/frontend/appflowy_web_app/src/assets/settings/dark.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src/assets/settings/light.png b/frontend/appflowy_web_app/src/assets/settings/light.png deleted file mode 100644 index 09b2d9c475..0000000000 Binary files a/frontend/appflowy_web_app/src/assets/settings/light.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src/assets/settings/workplace.svg b/frontend/appflowy_web_app/src/assets/settings/workplace.svg deleted file mode 100644 index 2076ea3e2c..0000000000 --- a/frontend/appflowy_web_app/src/assets/settings/workplace.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/show-menu.svg b/frontend/appflowy_web_app/src/assets/show-menu.svg deleted file mode 100644 index 8baf55bffd..0000000000 --- a/frontend/appflowy_web_app/src/assets/show-menu.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/sort.svg b/frontend/appflowy_web_app/src/assets/sort.svg deleted file mode 100644 index e3b6a49a56..0000000000 --- a/frontend/appflowy_web_app/src/assets/sort.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/strikethrough.svg b/frontend/appflowy_web_app/src/assets/strikethrough.svg deleted file mode 100644 index c118422a15..0000000000 --- a/frontend/appflowy_web_app/src/assets/strikethrough.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/text.svg b/frontend/appflowy_web_app/src/assets/text.svg deleted file mode 100644 index 7befa5080f..0000000000 --- a/frontend/appflowy_web_app/src/assets/text.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/todo-list.svg b/frontend/appflowy_web_app/src/assets/todo-list.svg deleted file mode 100644 index 37f52c47ed..0000000000 --- a/frontend/appflowy_web_app/src/assets/todo-list.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/underline.svg b/frontend/appflowy_web_app/src/assets/underline.svg deleted file mode 100644 index f5d53f0ec2..0000000000 --- a/frontend/appflowy_web_app/src/assets/underline.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/up.svg b/frontend/appflowy_web_app/src/assets/up.svg deleted file mode 100644 index bd8f3067d3..0000000000 --- a/frontend/appflowy_web_app/src/assets/up.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/components/_shared/not-found/RecordNotFound.tsx b/frontend/appflowy_web_app/src/components/_shared/not-found/RecordNotFound.tsx index 00441e5281..9216a92c69 100644 --- a/frontend/appflowy_web_app/src/components/_shared/not-found/RecordNotFound.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/not-found/RecordNotFound.tsx @@ -10,7 +10,7 @@ export function RecordNotFound({ open, workspaceId }: { workspaceId: string; ope Oops.. something went wrong - Sorry, the document you are looking for does not exist. + Sorry, the page you are looking for does not exist. diff --git a/frontend/appflowy_web_app/src/components/_shared/page/usePageInfo.tsx b/frontend/appflowy_web_app/src/components/_shared/page/usePageInfo.tsx index 4fec272b79..be0dc61dc7 100644 --- a/frontend/appflowy_web_app/src/components/_shared/page/usePageInfo.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/page/usePageInfo.tsx @@ -1,10 +1,10 @@ import { ViewLayout, YjsFolderKey, YView } from '@/application/collab.type'; import { useViewSelector } from '@/application/folder-yjs'; import React, { useMemo } from 'react'; -import { ReactComponent as DocumentSvg } from '@/assets/document.svg'; -import { ReactComponent as GridSvg } from '@/assets/grid.svg'; -import { ReactComponent as BoardSvg } from '@/assets/board.svg'; -import { ReactComponent as CalendarSvg } from '@/assets/date.svg'; +import { ReactComponent as DocumentSvg } from '$icons/16x/document.svg'; +import { ReactComponent as GridSvg } from '$icons/16x/grid.svg'; +import { ReactComponent as BoardSvg } from '$icons/16x/board.svg'; +import { ReactComponent as CalendarSvg } from '$icons/16x/date.svg'; import { useTranslation } from 'react-i18next'; export function usePageInfo(id: string) { diff --git a/frontend/appflowy_web_app/src/components/_shared/popover/Popover.tsx b/frontend/appflowy_web_app/src/components/_shared/popover/Popover.tsx new file mode 100644 index 0000000000..f91ac8284e --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/popover/Popover.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Popover as PopoverComponent, PopoverProps as PopoverComponentProps } from '@mui/material'; + +const defaultProps: Partial = { + keepMounted: false, + disableRestoreFocus: true, + anchorOrigin: { + vertical: 'bottom', + horizontal: 'left', + }, +}; + +export function Popover({ children, ...props }: PopoverComponentProps) { + return ( + + {children} + + ); +} diff --git a/frontend/appflowy_web_app/src/components/_shared/popover/index.ts b/frontend/appflowy_web_app/src/components/_shared/popover/index.ts new file mode 100644 index 0000000000..8f473de4b9 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/popover/index.ts @@ -0,0 +1 @@ +export * from './Popover'; diff --git a/frontend/appflowy_web_app/src/components/_shared/progress/LinearProgressWithLabel.tsx b/frontend/appflowy_web_app/src/components/_shared/progress/LinearProgressWithLabel.tsx new file mode 100644 index 0000000000..f12cfe4c01 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/progress/LinearProgressWithLabel.tsx @@ -0,0 +1,47 @@ +import React, { useMemo } from 'react'; + +function LinearProgressWithLabel({ + value, + count, + selectedCount, +}: { + value: number; + count: number; + selectedCount: number; +}) { + const result = useMemo(() => `${Math.round(value * 100)}%`, [value]); + + const options = useMemo(() => { + return Array.from({ length: count }, (_, i) => ({ + id: i, + checked: i < selectedCount, + })); + }, [count, selectedCount]); + + const isSplit = count < 6; + + return ( +
+
+ {options.map((option) => ( + + ))} +
+
{result}
+
+ ); +} + +export default LinearProgressWithLabel; diff --git a/frontend/appflowy_web_app/src/components/_shared/scroller/AFScroller.tsx b/frontend/appflowy_web_app/src/components/_shared/scroller/AFScroller.tsx index 0527b6cc26..9d07c8b908 100644 --- a/frontend/appflowy_web_app/src/components/_shared/scroller/AFScroller.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/scroller/AFScroller.tsx @@ -7,49 +7,67 @@ export interface AFScrollerProps { overflowYHidden?: boolean; className?: string; style?: React.CSSProperties; + onScroll?: (e: React.UIEvent) => void; } -export const AFScroller = ({ style, children, overflowXHidden, overflowYHidden, className }: AFScrollerProps) => { - return ( -
} - renderThumbVertical={(props) =>
} - {...(overflowXHidden && { - renderTrackHorizontal: (props) => ( + +export const AFScroller = React.forwardRef( + ({ onScroll, style, children, overflowXHidden, overflowYHidden, className }: AFScrollerProps, ref) => { + return ( + { + if (!el) return; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const scrollEl = el.container?.firstChild as HTMLElement; + + if (!scrollEl) return; + if (typeof ref === 'function') { + ref(scrollEl); + } else if (ref) { + ref.current = scrollEl; + } + }} + renderThumbHorizontal={(props) =>
} + renderThumbVertical={(props) =>
} + {...(overflowXHidden && { + renderTrackHorizontal: (props) => ( +
+ ), + })} + {...(overflowYHidden && { + renderTrackVertical: (props) => ( +
+ ), + })} + style={style} + renderView={(props) => (
- ), - })} - {...(overflowYHidden && { - renderTrackVertical: (props) => ( -
- ), - })} - style={style} - renderView={(props) => ( -
- )} - > - {children} - - ); -}; + )} + > + {children} + + ); + } +); diff --git a/frontend/appflowy_web_app/src/components/_shared/tag/Tag.tsx b/frontend/appflowy_web_app/src/components/_shared/tag/Tag.tsx new file mode 100644 index 0000000000..fbd9ac486d --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/tag/Tag.tsx @@ -0,0 +1,29 @@ +import { FC, useMemo } from 'react'; + +export interface TagProps { + color?: string; + label?: string; + size?: 'small' | 'medium'; +} + +export const Tag: FC = ({ color, size = 'small', label }) => { + const className = useMemo(() => { + const classList = ['rounded-md', 'font-medium', 'text-xs', 'leading-[18px]']; + + if (color) classList.push(`text-text-title`); + if (size === 'small') classList.push('text-xs', 'px-2', 'py-[2px]'); + if (size === 'medium') classList.push('text-sm', 'px-3', 'py-1'); + return classList.join(' '); + }, [color, size]); + + return ( +
+ {label} +
+ ); +}; diff --git a/frontend/appflowy_web_app/src/components/_shared/tag/index.ts b/frontend/appflowy_web_app/src/components/_shared/tag/index.ts new file mode 100644 index 0000000000..9790fcbf11 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/tag/index.ts @@ -0,0 +1 @@ +export * from './Tag'; diff --git a/frontend/appflowy_web_app/src/components/app/App.tsx b/frontend/appflowy_web_app/src/components/app/App.tsx index 1504c99f07..b2ee81eb20 100644 --- a/frontend/appflowy_web_app/src/components/app/App.tsx +++ b/frontend/appflowy_web_app/src/components/app/App.tsx @@ -10,7 +10,7 @@ const AppMain = withAppWrapper(() => { }> } /> - } /> + } /> } /> diff --git a/frontend/appflowy_web_app/src/components/app/AppTheme.tsx b/frontend/appflowy_web_app/src/components/app/AppTheme.tsx index 2d00bec2a3..179b371125 100644 --- a/frontend/appflowy_web_app/src/components/app/AppTheme.tsx +++ b/frontend/appflowy_web_app/src/components/app/AppTheme.tsx @@ -72,6 +72,7 @@ function AppTheme({ children }: { children: React.ReactNode }) { styleOverrides: { root: { backgroundImage: 'none', + boxShadow: 'var(--shadow)', }, }, }, @@ -100,6 +101,14 @@ function AppTheme({ children }: { children: React.ReactNode }) { }, }, MuiInputBase: { + defaultProps: { + sx: { + '&.Mui-disabled, .Mui-disabled': { + color: 'var(--text-caption)', + WebkitTextFillColor: 'var(--text-caption) !important', + }, + }, + }, styleOverrides: { input: { backgroundColor: 'transparent !important', diff --git a/frontend/appflowy_web_app/src/components/auth/Welcome.cy.tsx b/frontend/appflowy_web_app/src/components/auth/Welcome.cy.tsx index f0f83d366a..768cf3587b 100644 --- a/frontend/appflowy_web_app/src/components/auth/Welcome.cy.tsx +++ b/frontend/appflowy_web_app/src/components/auth/Welcome.cy.tsx @@ -9,7 +9,7 @@ describe('', () => { it('renders', () => { const AppWrapper = withAppWrapper(Welcome); - + cy.mount(); }); @@ -29,6 +29,7 @@ describe('', () => { cy.wait('@loginSuccess'); cy.wait('@verifyToken'); cy.wait('@getUserProfile'); + cy.wait('@getUserWorkspace'); cy.get('@dialog').should('not.exist'); }); }); diff --git a/frontend/appflowy_web_app/src/components/auth/auth.hooks.ts b/frontend/appflowy_web_app/src/components/auth/auth.hooks.ts index cb972283bf..affe339c81 100644 --- a/frontend/appflowy_web_app/src/components/auth/auth.hooks.ts +++ b/frontend/appflowy_web_app/src/components/auth/auth.hooks.ts @@ -56,6 +56,7 @@ export const useAuth = () => { throw new Error('Failed to check user'); } + console.log('userProfile', userProfile); await setUser(userProfile); return userProfile; diff --git a/frontend/appflowy_web_app/src/components/database/Database.tsx b/frontend/appflowy_web_app/src/components/database/Database.tsx new file mode 100644 index 0000000000..9e54b68ad0 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/Database.tsx @@ -0,0 +1,148 @@ +import { DatabaseViewLayout, YDatabase, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type'; +import { useId } from '@/components/_shared/context-provider/IdProvider'; +import RecordNotFound from '@/components/_shared/not-found/RecordNotFound'; +import { AFConfigContext } from '@/components/app/AppConfig'; +import { Board } from '@/components/database/board'; +import { Calendar } from '@/components/database/calendar'; +import { DatabaseConditionsContext } from '@/components/database/components/conditions/context'; +import { Grid } from '@/components/database/grid'; +import { DatabaseTabs, TabPanel } from '@/components/database/components/tabs'; +import { DatabaseContextProvider } from '@/components/database/DatabaseContext'; +import DatabaseTitle from '@/components/database/DatabaseTitle'; +import { Log } from '@/utils/log'; +import CircularProgress from '@mui/material/CircularProgress'; +import React, { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import SwipeableViews from 'react-swipeable-views'; +import DatabaseConditions from 'src/components/database/components/conditions/DatabaseConditions'; +import * as Y from 'yjs'; + +export const Database = memo(() => { + const { objectId, workspaceId } = useId() || {}; + const [search, setSearch] = useSearchParams(); + const viewId = search.get('v'); + + const [doc, setDoc] = useState(null); + const [rows, setRows] = useState | null>(null); // Map(false); + const databaseService = useContext(AFConfigContext)?.service?.databaseService; + + const handleOpenDocument = useCallback(async () => { + if (!databaseService || !workspaceId || !objectId) return; + + try { + setDoc(null); + const { databaseDoc, rows } = await databaseService.openDatabase(workspaceId, objectId); + + console.log('databaseDoc', databaseDoc.getMap(YjsEditorKey.data_section).toJSON()); + setDoc(databaseDoc); + setRows(rows); + } catch (e) { + Log.error(e); + setNotFound(true); + } + }, [databaseService, workspaceId, objectId]); + + useEffect(() => { + setNotFound(false); + void handleOpenDocument(); + }, [handleOpenDocument]); + + const database = useMemo(() => doc?.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database) as YDatabase, [doc]); + + const views = useMemo(() => database?.get(YjsDatabaseKey.views), [database]); + + const handleChangeView = useCallback( + (viewId: string) => { + setSearch({ v: viewId }); + }, + [setSearch] + ); + + const viewIds = useMemo(() => (views ? Array.from(views.keys()) : []), [views]); + + const value = useMemo(() => { + return Math.max( + 0, + viewIds.findIndex((id) => id === (viewId ?? objectId)) + ); + }, [viewId, viewIds, objectId]); + + const getDatabaseViewComponent = useCallback((layout: DatabaseViewLayout) => { + switch (layout) { + case DatabaseViewLayout.Grid: + return Grid; + case DatabaseViewLayout.Board: + return Board; + case DatabaseViewLayout.Calendar: + return Calendar; + } + }, []); + + const [conditionsExpanded, setConditionsExpanded] = useState(false); + const toggleExpanded = useCallback(() => { + setConditionsExpanded((prev) => !prev); + }, []); + + console.log('viewId', viewId, 'objectId', doc, objectId, database); + if (!objectId) return null; + + if (!doc) { + return ; + } + + if (!rows) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ + + + + + + {viewIds.map((viewId, index) => { + const layout = Number(views.get(viewId)?.get(YjsDatabaseKey.layout)) as DatabaseViewLayout; + const Component = getDatabaseViewComponent(layout); + + return ( + + + + ); + })} + + +
+
+ ); +}); + +export default Database; diff --git a/frontend/appflowy_web_app/src/components/database/DatabaseContext.tsx b/frontend/appflowy_web_app/src/components/database/DatabaseContext.tsx new file mode 100644 index 0000000000..8adc87d4e6 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/DatabaseContext.tsx @@ -0,0 +1,10 @@ +import { DatabaseContext, DatabaseContextState } from '@/application/database-yjs'; + +export const DatabaseContextProvider = ({ + children, + ...props +}: DatabaseContextState & { + children: React.ReactNode; +}) => { + return {children}; +}; diff --git a/frontend/appflowy_web_app/src/components/database/DatabaseTitle.tsx b/frontend/appflowy_web_app/src/components/database/DatabaseTitle.tsx new file mode 100644 index 0000000000..baf314130e --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/DatabaseTitle.tsx @@ -0,0 +1,19 @@ +import { usePageInfo } from '@/components/_shared/page/usePageInfo'; +import React from 'react'; + +function DatabaseTitle({ viewId }: { viewId: string }) { + const { name, icon } = usePageInfo(viewId); + + return ( +
+
+
+
{icon}
+
{name}
+
+
+
+ ); +} + +export default DatabaseTitle; diff --git a/frontend/appflowy_web_app/src/components/database/board/Board.tsx b/frontend/appflowy_web_app/src/components/database/board/Board.tsx new file mode 100644 index 0000000000..eabc9c2631 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/board/Board.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export function Board() { + return
Board
; +} + +export default Board; diff --git a/frontend/appflowy_web_app/src/components/database/board/index.ts b/frontend/appflowy_web_app/src/components/database/board/index.ts new file mode 100644 index 0000000000..9294d869ce --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/board/index.ts @@ -0,0 +1 @@ +export * from './Board'; diff --git a/frontend/appflowy_web_app/src/components/database/calendar/Calendar.tsx b/frontend/appflowy_web_app/src/components/database/calendar/Calendar.tsx new file mode 100644 index 0000000000..c21e37b362 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/calendar/Calendar.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export function Calendar() { + return
Calendar
; +} + +export default Calendar; diff --git a/frontend/appflowy_web_app/src/components/database/calendar/index.ts b/frontend/appflowy_web_app/src/components/database/calendar/index.ts new file mode 100644 index 0000000000..a723380592 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/calendar/index.ts @@ -0,0 +1 @@ +export * from './Calendar'; diff --git a/frontend/appflowy_web_app/src/components/database/components/calculation-cell/CalculationCell.tsx b/frontend/appflowy_web_app/src/components/database/components/calculation-cell/CalculationCell.tsx new file mode 100644 index 0000000000..eeefee18bb --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/calculation-cell/CalculationCell.tsx @@ -0,0 +1,40 @@ +import { CalculationType } from '@/application/database-yjs/database.type'; +import { CalulationCell } from '@/components/database/components/calculation-cell/cell.type'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +export function CalculationCell({ cell }: { cell?: CalulationCell }) { + const { t } = useTranslation(); + + const prefix = useMemo(() => { + if (!cell) return ''; + + switch (cell.type) { + case CalculationType.Average: + return t('grid.calculationTypeLabel.average'); + case CalculationType.Max: + return t('grid.calculationTypeLabel.max'); + case CalculationType.Count: + return t('grid.calculationTypeLabel.count'); + case CalculationType.Min: + return t('grid.calculationTypeLabel.min'); + case CalculationType.Sum: + return t('grid.calculationTypeLabel.sum'); + case CalculationType.CountEmpty: + return t('grid.calculationTypeLabel.countEmptyShort'); + case CalculationType.CountNonEmpty: + return t('grid.calculationTypeLabel.countNonEmptyShort'); + default: + return ''; + } + }, [cell, t]); + + return ( +
+ {prefix} + {cell?.value ?? ''} +
+ ); +} + +export default CalculationCell; diff --git a/frontend/appflowy_web_app/src/components/database/components/calculation-cell/cell.type.ts b/frontend/appflowy_web_app/src/components/database/components/calculation-cell/cell.type.ts new file mode 100644 index 0000000000..ef44e2e745 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/calculation-cell/cell.type.ts @@ -0,0 +1,8 @@ +import { CalculationType } from '@/application/database-yjs/database.type'; + +export interface CalulationCell { + value: string; + fieldId: string; + id: string; + type: CalculationType; +} diff --git a/frontend/appflowy_web_app/src/components/database/components/calculation-cell/index.ts b/frontend/appflowy_web_app/src/components/database/components/calculation-cell/index.ts new file mode 100644 index 0000000000..9bf73af548 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/calculation-cell/index.ts @@ -0,0 +1 @@ +export * from './CalculationCell'; diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/Cell.hooks.ts b/frontend/appflowy_web_app/src/components/database/components/cell/Cell.hooks.ts new file mode 100644 index 0000000000..1012dd4543 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/cell/Cell.hooks.ts @@ -0,0 +1,47 @@ +import { YjsDatabaseKey } from '@/application/collab.type'; +import { FieldType } from '@/application/database-yjs/database.type'; +import { useFieldSelector } from '@/application/database-yjs/selector'; +import { DateFormat, TimeFormat, getDateFormat, getTimeFormat } from '@/application/database-yjs'; +import { renderDate } from '@/utils/time'; +import { useCallback, useMemo } from 'react'; + +export function useCellTypeOption(fieldId: string) { + const { field } = useFieldSelector(fieldId); + const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType; + + return useMemo(() => { + return field?.get(YjsDatabaseKey.type_option)?.get(String(fieldType)); + }, [fieldType, field]); +} + +export function useDateTypeCellDispatcher(fieldId: string) { + const typeOption = useCellTypeOption(fieldId); + const typeOptionValue = useMemo(() => { + if (!typeOption) return null; + return { + timeFormat: parseInt(typeOption.get(YjsDatabaseKey.time_format)) as TimeFormat, + dateFormat: parseInt(typeOption.get(YjsDatabaseKey.date_format)) as DateFormat, + }; + }, [typeOption]); + + const getDateTimeStr = useCallback( + (timeStamp: string, includeTime?: boolean) => { + if (!typeOptionValue) 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); + }, + [typeOptionValue] + ); + + return { + getDateTimeStr, + typeOptionValue, + }; +} diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/Cell.tsx b/frontend/appflowy_web_app/src/components/database/components/cell/Cell.tsx new file mode 100644 index 0000000000..ee3cde673b --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/cell/Cell.tsx @@ -0,0 +1,62 @@ +import { FieldId, YjsDatabaseKey } from '@/application/collab.type'; +import { FieldType } from '@/application/database-yjs/database.type'; +import { useFieldSelector } from '@/application/database-yjs/selector'; +import RowCreateModifiedTime from '@/components/database/components/cell/RowCreateModifiedTime'; +import React, { FC, useMemo } from 'react'; +import RichTextCell from '@/components/database/components/cell/TextCell'; +import UrlCell from '@/components/database/components/cell/UrlCell'; +import NumberCell from '@/components/database/components/cell/NumberCell'; +import CheckboxCell from '@/components/database/components/cell/CheckboxCell'; +import SelectCell from '@/components/database/components/cell/SelectionCell'; +import DateTimeCell from '@/components/database/components/cell/DateTimeCell'; +import ChecklistCell from '@/components/database/components/cell/ChecklistCell'; +import { Cell as CellValue } from '@/components/database/components/cell/cell.type'; +import RelationCell from '@/components/database/components/cell/RelationCell'; + +export interface CellProps { + rowId: string; + fieldId: FieldId; + cell?: CellValue; +} + +export function Cell({ cell, rowId, fieldId }: CellProps) { + const { field } = useFieldSelector(fieldId); + const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType; + const Component = useMemo(() => { + switch (fieldType) { + case FieldType.RichText: + return RichTextCell; + case FieldType.URL: + return UrlCell; + case FieldType.Number: + return NumberCell; + case FieldType.Checkbox: + return CheckboxCell; + case FieldType.SingleSelect: + case FieldType.MultiSelect: + return SelectCell; + case FieldType.DateTime: + return DateTimeCell; + case FieldType.Checklist: + return ChecklistCell; + case FieldType.Relation: + return RelationCell; + default: + return RichTextCell; + } + }, [fieldType]) as FC<{ cell?: CellValue; rowId: string; fieldId: FieldId }>; + + if (fieldType === FieldType.CreatedTime || fieldType === FieldType.LastEditedTime) { + const attrName = fieldType === FieldType.CreatedTime ? YjsDatabaseKey.created_at : YjsDatabaseKey.last_modified; + + return ; + } + + if (cell?.fieldType !== fieldType) { + return null; + } + + return ; +} + +export default Cell; diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/CheckboxCell.tsx b/frontend/appflowy_web_app/src/components/database/components/cell/CheckboxCell.tsx new file mode 100644 index 0000000000..558c424f62 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/cell/CheckboxCell.tsx @@ -0,0 +1,14 @@ +import { FieldId } from '@/application/collab.type'; +import { ReactComponent as CheckboxCheckSvg } from '$icons/16x/check_filled.svg'; +import { ReactComponent as CheckboxUncheckSvg } from '$icons/16x/uncheck.svg'; +import { CheckboxCell } from '@/components/database/components/cell/cell.type'; + +export default function ({ cell }: { cell?: CheckboxCell; rowId: string; fieldId: FieldId }) { + const checked = cell?.data; + + return ( +
+ {checked ? : } +
+ ); +} diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/ChecklistCell.tsx b/frontend/appflowy_web_app/src/components/database/components/cell/ChecklistCell.tsx new file mode 100644 index 0000000000..32d97d758f --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/cell/ChecklistCell.tsx @@ -0,0 +1,21 @@ +import { FieldId } from '@/application/collab.type'; +import { parseChecklistData } from '@/application/database-yjs'; +import { ChecklistCell } from '@/components/database/components/cell/cell.type'; +import LinearProgressWithLabel from '@/components/_shared/progress/LinearProgressWithLabel'; +import React, { useMemo } from 'react'; + +export default function ({ cell }: { cell?: ChecklistCell; rowId: string; fieldId: FieldId }) { + const data = useMemo(() => { + return parseChecklistData(cell?.data ?? ''); + }, [cell?.data]); + + const options = data?.options; + const selectedOptions = data?.selectedOptionIds; + + if (!data || !options || !selectedOptions) return null; + return ( +
+ +
+ ); +} diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/DateTimeCell.tsx b/frontend/appflowy_web_app/src/components/database/components/cell/DateTimeCell.tsx new file mode 100644 index 0000000000..490a2bd95e --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/cell/DateTimeCell.tsx @@ -0,0 +1,35 @@ +import { FieldId } from '@/application/collab.type'; +import { useDateTypeCellDispatcher } from '@/components/database/components/cell/Cell.hooks'; +import { DateTimeCell } from '@/components/database/components/cell/cell.type'; +import React, { useMemo } from 'react'; +import { ReactComponent as ReminderSvg } from '$icons/16x/clock_alarm.svg'; + +export default function ({ cell, fieldId }: { cell?: DateTimeCell; rowId: string; fieldId: FieldId }) { + const { getDateTimeStr } = useDateTypeCellDispatcher(fieldId); + + const startDateTime = useMemo(() => { + return getDateTimeStr(cell?.data || '', cell?.includeTime); + }, [cell, getDateTimeStr]); + + const endDateTime = useMemo(() => { + if (!cell) return null; + const { endTimestamp, isRange } = cell; + + if (!isRange) return null; + + return getDateTimeStr(endTimestamp || '', cell?.includeTime); + }, [cell, getDateTimeStr]); + + const dateStr = useMemo(() => { + return [startDateTime, endDateTime].filter(Boolean).join(' -> '); + }, [startDateTime, endDateTime]); + + const hasReminder = !!cell?.reminderId; + + return ( +
+ {hasReminder && } + {dateStr} +
+ ); +} diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/NumberCell.tsx b/frontend/appflowy_web_app/src/components/database/components/cell/NumberCell.tsx new file mode 100644 index 0000000000..851e14a34e --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/cell/NumberCell.tsx @@ -0,0 +1,27 @@ +import { FieldId } from '@/application/collab.type'; +import { currencyFormaterMap, NumberFormat, useFieldSelector, parseNumberTypeOptions } from '@/application/database-yjs'; +import { UrlCell } from '@/components/database/components/cell/cell.type'; +import React, { useMemo } from 'react'; +import Decimal from 'decimal.js'; + +export default function ({ cell, fieldId }: { cell?: UrlCell; rowId: string; fieldId: FieldId }) { + const { field } = useFieldSelector(fieldId); + + const format = useMemo(() => (field ? parseNumberTypeOptions(field).format : NumberFormat.Num), [field]); + + const className = useMemo(() => { + const classList = ['select-text', 'cursor-text']; + + return classList.join(' '); + }, []); + + const value = useMemo(() => { + if (!cell) return ''; + const numberFormater = currencyFormaterMap[format]; + + if (!numberFormater) return cell.data; + return numberFormater(new Decimal(cell.data).toNumber()); + }, [cell, format]); + + return
{value}
; +} diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/RelationCell.tsx b/frontend/appflowy_web_app/src/components/database/components/cell/RelationCell.tsx new file mode 100644 index 0000000000..56c1e8d27b --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/cell/RelationCell.tsx @@ -0,0 +1,84 @@ +import { + FieldId, + YDatabaseField, + YDatabaseFields, + YDatabaseRow, + YDoc, + YjsDatabaseKey, + YjsEditorKey, +} from '@/application/collab.type'; +import { useFieldSelector, parseRelationTypeOption } from '@/application/database-yjs'; +import { useId } from '@/components/_shared/context-provider/IdProvider'; +import { AFConfigContext } from '@/components/app/AppConfig'; +import { parseYDatabaseCellToCell } from '@/components/database/components/cell/cell.parse'; +import { RelationCell, RelationCellData } from '@/components/database/components/cell/cell.type'; +import React, { useContext, useEffect, useMemo, useState } from 'react'; +import * as Y from 'yjs'; + +export default function ({ cell, fieldId }: { cell?: RelationCell; fieldId: string; rowId: string }) { + const { field } = useFieldSelector(fieldId); + const workspaceId = useId()?.workspaceId; + const rowIds = useMemo(() => (cell?.data.toJSON() as RelationCellData) ?? [], [cell?.data]); + const databaseId = rowIds.length > 0 && field ? parseRelationTypeOption(field).database_id : undefined; + const databaseService = useContext(AFConfigContext)?.service?.databaseService; + const [databasePrimaryFieldId, setDatabasePrimaryFieldId] = useState(undefined); + const [rows, setRows] = useState | null>(); + + useEffect(() => { + if (!workspaceId || !databaseId) return; + void databaseService?.getDatabase(workspaceId, databaseId).then(({ databaseDoc: doc, rows }) => { + const fields = doc + .getMap(YjsEditorKey.data_section) + .get(YjsEditorKey.database) + .get(YjsDatabaseKey.fields) as YDatabaseFields; + + fields.forEach((field, fieldId) => { + if ((field as YDatabaseField).get(YjsDatabaseKey.is_primary)) { + setDatabasePrimaryFieldId(fieldId); + } + }); + + setRows(rows); + }); + }, [workspaceId, databaseId, databaseService]); + + return ( +
+ {rowIds.map((rowId) => { + const rowDoc = rows?.get(rowId); + + return ( +
+ {rowDoc && databasePrimaryFieldId && ( + + )} +
+ ); + })} +
+ ); +} + +function RelationPrimaryValue({ rowDoc, fieldId }: { rowDoc: YDoc; fieldId: FieldId }) { + const [text, setText] = useState(null); + + useEffect(() => { + const row = rowDoc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database_row) as YDatabaseRow; + const cells = row.get(YjsDatabaseKey.cells); + const primaryCell = cells.get(fieldId); + + if (!primaryCell) return; + const observeHandler = () => { + setText(parseYDatabaseCellToCell(primaryCell).data as string); + }; + + observeHandler(); + + primaryCell.observe(observeHandler); + return () => { + primaryCell.unobserve(observeHandler); + }; + }, [rowDoc, fieldId]); + + return
{text}
; +} diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/RowCreateModifiedTime.tsx b/frontend/appflowy_web_app/src/components/database/components/cell/RowCreateModifiedTime.tsx new file mode 100644 index 0000000000..d685b53cf9 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/cell/RowCreateModifiedTime.tsx @@ -0,0 +1,43 @@ +import { YjsDatabaseKey } from '@/application/collab.type'; +import { useRowMeta } from '@/application/database-yjs'; +import { useDateTypeCellDispatcher } from '@/components/database/components/cell/Cell.hooks'; +import React, { useEffect, useMemo, useState } from 'react'; + +function RowCreateModifiedTime({ + rowId, + fieldId, + attrName, +}: { + rowId: string; + fieldId: string; + attrName: YjsDatabaseKey.last_modified | YjsDatabaseKey.created_at; +}) { + const { getDateTimeStr } = useDateTypeCellDispatcher(fieldId); + const rowMeta = useRowMeta(rowId); + const [value, setValue] = useState(null); + + useEffect(() => { + if (!rowMeta) return; + const observeHandler = () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + setValue(rowMeta.get(attrName)); + }; + + observeHandler(); + + rowMeta.observe(observeHandler); + return () => { + rowMeta.unobserve(observeHandler); + }; + }, [rowMeta, attrName]); + + const time = useMemo(() => { + if (!value) return null; + return getDateTimeStr(value, false); + }, [value, getDateTimeStr]); + + return
{time}
; +} + +export default RowCreateModifiedTime; diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/SelectionCell.tsx b/frontend/appflowy_web_app/src/components/database/components/cell/SelectionCell.tsx new file mode 100644 index 0000000000..a915d31a9b --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/cell/SelectionCell.tsx @@ -0,0 +1,32 @@ +import { FieldId } from '@/application/collab.type'; +import { useFieldSelector, parseSelectOptionTypeOptions } from '@/application/database-yjs'; +import { Tag } from '@/components/_shared/tag'; +import { SelectOptionColorMap } from '@/components/database/components/cell/cell.const'; +import { SelectCell } from '@/components/database/components/cell/cell.type'; +import React, { useCallback, useMemo } from 'react'; + +export default function ({ cell, fieldId }: { cell?: SelectCell; rowId: string; fieldId: FieldId }) { + const selectOptionIds = useMemo(() => cell?.data.split(','), [cell]); + const { field } = useFieldSelector(fieldId); + const typeOption = useMemo(() => { + if (!field) return null; + return parseSelectOptionTypeOptions(field); + }, [field]); + + const renderSelectedOptions = useCallback( + (selected: string[]) => + selected.map((id) => { + const option = typeOption?.options?.find((option) => option.id === id); + + if (!option) return null; + return ; + }), + [typeOption] + ); + + return ( +
+ {selectOptionIds ? renderSelectedOptions(selectOptionIds) : null} +
+ ); +} diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/TextCell.tsx b/frontend/appflowy_web_app/src/components/database/components/cell/TextCell.tsx new file mode 100644 index 0000000000..f9c8749258 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/cell/TextCell.tsx @@ -0,0 +1,12 @@ +import { FieldId } from '@/application/collab.type'; +import { useReadOnly } from '@/application/database-yjs'; +import { TextCell } from '@/components/database/components/cell/cell.type'; +import React from 'react'; + +function TextCellComponent({ cell }: { cell?: TextCell; rowId: string; fieldId: FieldId }) { + const readOnly = useReadOnly(); + + return
{cell?.data}
; +} + +export default TextCellComponent; diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/UrlCell.tsx b/frontend/appflowy_web_app/src/components/database/components/cell/UrlCell.tsx new file mode 100644 index 0000000000..e2d3d2c87f --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/cell/UrlCell.tsx @@ -0,0 +1,37 @@ +import { FieldId } from '@/application/collab.type'; +import { useReadOnly } from '@/application/database-yjs'; +import { UrlCell } from '@/components/database/components/cell/cell.type'; +import { openUrl, processUrl } from '@/utils/url'; +import React, { useMemo } from 'react'; + +export default function ({ cell }: { cell?: UrlCell; rowId: string; fieldId: FieldId }) { + const readOnly = useReadOnly(); + + const isUrl = useMemo(() => (cell ? processUrl(cell.data) : false), [cell]); + + const className = useMemo(() => { + const classList = ['select-text']; + + if (isUrl) { + classList.push('text-content-blue-400', 'underline', 'cursor-pointer'); + } else { + classList.push('cursor-text'); + } + + return classList.join(' '); + }, [isUrl]); + + return ( +
{ + if (!isUrl || !cell) return; + if (readOnly) { + void openUrl(cell.data, '_blank'); + } + }} + className={className} + > + {cell?.data} +
+ ); +} diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/cell.const.ts b/frontend/appflowy_web_app/src/components/database/components/cell/cell.const.ts new file mode 100644 index 0000000000..d9e3564096 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/cell/cell.const.ts @@ -0,0 +1,25 @@ +import { SelectOptionColor } from '@/application/database-yjs'; + +export const SelectOptionColorMap = { + [SelectOptionColor.Purple]: '--tint-purple', + [SelectOptionColor.Pink]: '--tint-pink', + [SelectOptionColor.LightPink]: '--tint-red', + [SelectOptionColor.Orange]: '--tint-orange', + [SelectOptionColor.Yellow]: '--tint-yellow', + [SelectOptionColor.Lime]: '--tint-lime', + [SelectOptionColor.Green]: '--tint-green', + [SelectOptionColor.Aqua]: '--tint-aqua', + [SelectOptionColor.Blue]: '--tint-blue', +}; + +export const SelectOptionColorTextMap = { + [SelectOptionColor.Purple]: 'purpleColor', + [SelectOptionColor.Pink]: 'pinkColor', + [SelectOptionColor.LightPink]: 'lightPinkColor', + [SelectOptionColor.Orange]: 'orangeColor', + [SelectOptionColor.Yellow]: 'yellowColor', + [SelectOptionColor.Lime]: 'limeColor', + [SelectOptionColor.Green]: 'greenColor', + [SelectOptionColor.Aqua]: 'aquaColor', + [SelectOptionColor.Blue]: 'blueColor', +} as const; diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/cell.parse.ts b/frontend/appflowy_web_app/src/components/database/components/cell/cell.parse.ts new file mode 100644 index 0000000000..4124381c06 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/cell/cell.parse.ts @@ -0,0 +1,46 @@ +import { YDatabaseCell, YjsDatabaseKey } from '@/application/collab.type'; +import { FieldType } from '@/application/database-yjs/database.type'; +import { Cell, CheckboxCell, DateTimeCell } from './cell.type'; + +export function parseYDatabaseCommonCellToCell(cell: YDatabaseCell): Cell { + return { + createdAt: Number(cell.get(YjsDatabaseKey.created_at)), + lastModified: Number(cell.get(YjsDatabaseKey.last_modified)), + fieldType: parseInt(cell.get(YjsDatabaseKey.field_type)) as FieldType, + data: cell.get(YjsDatabaseKey.data), + }; +} + +export function parseYDatabaseCellToCell(cell: YDatabaseCell): Cell { + const fieldType = parseInt(cell.get(YjsDatabaseKey.field_type)); + + if (fieldType === FieldType.DateTime) { + return parseYDatabaseDateTimeCellToCell(cell); + } + + if (fieldType === FieldType.Checkbox) { + return parseYDatabaseCheckboxCellToCell(cell); + } + + return parseYDatabaseCommonCellToCell(cell); +} + +export function parseYDatabaseDateTimeCellToCell(cell: YDatabaseCell): DateTimeCell { + return { + ...parseYDatabaseCommonCellToCell(cell), + data: cell.get(YjsDatabaseKey.data) as string, + fieldType: FieldType.DateTime, + endTimestamp: cell.get(YjsDatabaseKey.end_timestamp), + includeTime: cell.get(YjsDatabaseKey.include_time), + isRange: cell.get(YjsDatabaseKey.is_range), + reminderId: cell.get(YjsDatabaseKey.reminder_id), + }; +} + +export function parseYDatabaseCheckboxCellToCell(cell: YDatabaseCell): CheckboxCell { + return { + ...parseYDatabaseCommonCellToCell(cell), + data: cell.get(YjsDatabaseKey.data) === 'Yes', + fieldType: FieldType.Checkbox, + }; +} diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/cell.type.ts b/frontend/appflowy_web_app/src/components/database/components/cell/cell.type.ts new file mode 100644 index 0000000000..185cca9409 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/cell/cell.type.ts @@ -0,0 +1,90 @@ +import { RowId } from '@/application/collab.type'; +import { DateFormat, SelectOption, TimeFormat } from '@/application/database-yjs'; +import { FieldType } from '@/application/database-yjs/database.type'; +import { YArray } from 'yjs/dist/src/types/YArray'; + +export interface Cell { + createdAt: number; + lastModified: number; + fieldType: FieldType; + data: unknown; +} + +export interface TextCell extends Cell { + fieldType: FieldType.RichText; + data: string; +} + +export interface NumberCell extends Cell { + fieldType: FieldType.Number; + data: string; +} + +export interface CheckboxCell extends Cell { + fieldType: FieldType.Checkbox; + data: boolean; +} + +export interface UrlCell extends Cell { + fieldType: FieldType.URL; + data: string; +} + +export type SelectionId = string; + +export interface SelectCell extends Cell { + fieldType: FieldType.SingleSelect | FieldType.MultiSelect; + data: SelectionId; +} + +export interface DataTimeTypeOption { + timeFormat: TimeFormat; + dateFormat: DateFormat; +} + +export interface DateTimeCell extends Cell { + fieldType: FieldType.DateTime; + data: string; + endTimestamp?: string; + includeTime?: boolean; + isRange?: boolean; + reminderId?: string; +} + +export interface TimeStampCell extends Cell { + fieldType: FieldType.LastEditedTime | FieldType.CreatedTime; + data: TimestampCellData; +} + +export interface DateTimeCellData { + date?: string; + time?: string; + timestamp?: number; + includeTime?: boolean; + endDate?: string; + endTime?: string; + endTimestamp?: number; + isRange?: boolean; +} + +export interface TimestampCellData { + dataTime?: string; + timestamp?: number; +} + +export interface ChecklistCell extends Cell { + fieldType: FieldType.Checklist; + data: string; +} + +export interface RelationCell extends Cell { + fieldType: FieldType.Relation; + data: YArray; +} + +export type RelationCellData = RowId[]; + +export interface ChecklistCellData { + selected_option_ids?: string[]; + options?: SelectOption[]; +} diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/index.ts b/frontend/appflowy_web_app/src/components/database/components/cell/index.ts new file mode 100644 index 0000000000..2440976340 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/cell/index.ts @@ -0,0 +1 @@ +export * from './Cell'; diff --git a/frontend/appflowy_web_app/src/components/database/components/conditions/DatabaseActions.tsx b/frontend/appflowy_web_app/src/components/database/components/conditions/DatabaseActions.tsx new file mode 100644 index 0000000000..6b4a836597 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/conditions/DatabaseActions.tsx @@ -0,0 +1,35 @@ +import { useFiltersSelector, useSortsSelector } from '@/application/database-yjs'; +import { useConditionsContext } from '@/components/database/components/conditions/context'; +import { TextButton } from '@/components/database/components/tabs/TextButton'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export function DatabaseActions() { + const { t } = useTranslation(); + const sorts = useSortsSelector(); + const filter = useFiltersSelector(); + const conditionsContext = useConditionsContext(); + + return ( +
+ { + conditionsContext?.toggleExpanded(); + }} + color={filter.length > 0 ? 'primary' : 'inherit'} + > + {t('grid.settings.filter')} + + { + conditionsContext?.toggleExpanded(); + }} + color={sorts.length > 0 ? 'primary' : 'inherit'} + > + {t('grid.settings.sort')} + +
+ ); +} + +export default DatabaseActions; diff --git a/frontend/appflowy_web_app/src/components/database/components/conditions/DatabaseConditions.tsx b/frontend/appflowy_web_app/src/components/database/components/conditions/DatabaseConditions.tsx new file mode 100644 index 0000000000..fc36c470d6 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/conditions/DatabaseConditions.tsx @@ -0,0 +1,33 @@ +import { useFiltersSelector, useSortsSelector } from '@/application/database-yjs'; +import { AFScroller } from '@/components/_shared/scroller'; +import { useConditionsContext } from '@/components/database/components/conditions/context'; +import React from 'react'; +import Filters from 'src/components/database/components/filters/Filters'; +import Sorts from 'src/components/database/components/sorts/Sorts'; + +export function DatabaseConditions() { + const conditionsContext = useConditionsContext(); + const expanded = conditionsContext?.expanded ?? false; + const sorts = useSortsSelector(); + const filters = useFiltersSelector(); + + return ( +
+ + + {sorts.length > 0 && filters.length > 0 &&
} + + +
+ ); +} + +export default DatabaseConditions; diff --git a/frontend/appflowy_web_app/src/components/database/components/conditions/context.ts b/frontend/appflowy_web_app/src/components/database/components/conditions/context.ts new file mode 100644 index 0000000000..aadb5007af --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/conditions/context.ts @@ -0,0 +1,12 @@ +import { createContext, useContext } from 'react'; + +interface DatabaseConditionsContextType { + expanded: boolean; + toggleExpanded: () => void; +} + +export function useConditionsContext() { + return useContext(DatabaseConditionsContext); +} + +export const DatabaseConditionsContext = createContext(undefined); diff --git a/frontend/appflowy_web_app/src/components/database/components/conditions/index.ts b/frontend/appflowy_web_app/src/components/database/components/conditions/index.ts new file mode 100644 index 0000000000..7b30286c5c --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/conditions/index.ts @@ -0,0 +1,2 @@ +export * from './DatabaseActions'; +export * from './DatabaseConditions'; diff --git a/frontend/appflowy_web_app/src/components/database/components/field/FieldDisplay.tsx b/frontend/appflowy_web_app/src/components/database/components/field/FieldDisplay.tsx new file mode 100644 index 0000000000..3ff135e8f7 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/field/FieldDisplay.tsx @@ -0,0 +1,20 @@ +import { FieldId, YjsDatabaseKey } from '@/application/collab.type'; +import { FieldType, useFieldSelector } from '@/application/database-yjs'; +import { FieldTypeIcon } from '@/components/database/components/field/FieldTypeIcon'; +import React from 'react'; + +export function FieldDisplay({ fieldId }: { fieldId: FieldId }) { + const { field } = useFieldSelector(fieldId); + const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType; + + if (!field) return null; + + return ( +
+ + {field?.get(YjsDatabaseKey.name)} +
+ ); +} + +export default FieldDisplay; diff --git a/frontend/appflowy_web_app/src/components/database/components/field/FieldTypeIcon.tsx b/frontend/appflowy_web_app/src/components/database/components/field/FieldTypeIcon.tsx new file mode 100644 index 0000000000..3749e21afd --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/field/FieldTypeIcon.tsx @@ -0,0 +1,33 @@ +import { FieldType } from '@/application/database-yjs/database.type'; +import { FC, memo } from 'react'; +import { ReactComponent as TextSvg } from '$icons/16x/text.svg'; +import { ReactComponent as NumberSvg } from '$icons/16x/number.svg'; +import { ReactComponent as DateSvg } from '$icons/16x/date.svg'; +import { ReactComponent as SingleSelectSvg } from '$icons/16x/single_select.svg'; +import { ReactComponent as MultiSelectSvg } from '$icons/16x/multiselect.svg'; +import { ReactComponent as ChecklistSvg } from '$icons/16x/checklist.svg'; +import { ReactComponent as CheckboxSvg } from '$icons/16x/checkbox.svg'; +import { ReactComponent as URLSvg } from '$icons/16x/url.svg'; +import { ReactComponent as LastEditedTimeSvg } from '$icons/16x/last_modified.svg'; +import { ReactComponent as CreatedSvg } from '$icons/16x/created_at.svg'; +import { ReactComponent as RelationSvg } from '$icons/16x/relation.svg'; + +export const FieldTypeSvgMap: Record>> = { + [FieldType.RichText]: TextSvg, + [FieldType.Number]: NumberSvg, + [FieldType.DateTime]: DateSvg, + [FieldType.SingleSelect]: SingleSelectSvg, + [FieldType.MultiSelect]: MultiSelectSvg, + [FieldType.Checkbox]: CheckboxSvg, + [FieldType.URL]: URLSvg, + [FieldType.Checklist]: ChecklistSvg, + [FieldType.LastEditedTime]: LastEditedTimeSvg, + [FieldType.CreatedTime]: CreatedSvg, + [FieldType.Relation]: RelationSvg, +}; + +export const FieldTypeIcon: FC<{ type: FieldType; className?: string }> = memo(({ type, ...props }) => { + const Svg = FieldTypeSvgMap[type]; + + return ; +}); diff --git a/frontend/appflowy_web_app/src/components/database/components/field/index.ts b/frontend/appflowy_web_app/src/components/database/components/field/index.ts new file mode 100644 index 0000000000..85ff96da07 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/field/index.ts @@ -0,0 +1,2 @@ +export * from './FieldTypeIcon'; +export * from './FieldDisplay'; diff --git a/frontend/appflowy_web_app/src/components/database/components/field/select-option/SelectOptionList.tsx b/frontend/appflowy_web_app/src/components/database/components/field/select-option/SelectOptionList.tsx new file mode 100644 index 0000000000..353ef5d349 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/field/select-option/SelectOptionList.tsx @@ -0,0 +1,30 @@ +import { parseSelectOptionTypeOptions, SelectOption, useFieldSelector } from '@/application/database-yjs'; +import { Tag } from '@/components/_shared/tag'; +import { SelectOptionColorMap } from '@/components/database/components/cell/cell.const'; +import React, { useCallback, useMemo } from 'react'; +import { ReactComponent as CheckIcon } from '$icons/16x/check.svg'; + +export function SelectOptionList({ fieldId, selectedIds }: { fieldId: string; selectedIds: string[] }) { + const { field } = useFieldSelector(fieldId); + const typeOption = useMemo(() => { + if (!field) return null; + return parseSelectOptionTypeOptions(field); + }, [field]); + + const renderOption = useCallback( + (option: SelectOption) => { + const isSelected = selectedIds.includes(option.id); + + return ( +
+ + {isSelected && } +
+ ); + }, + [selectedIds] + ); + + if (!field || !typeOption) return null; + return
{typeOption.options.map(renderOption)}
; +} diff --git a/frontend/appflowy_web_app/src/components/database/components/field/select-option/index.ts b/frontend/appflowy_web_app/src/components/database/components/field/select-option/index.ts new file mode 100644 index 0000000000..20465070b4 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/field/select-option/index.ts @@ -0,0 +1 @@ +export * from './SelectOptionList'; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/Filter.tsx b/frontend/appflowy_web_app/src/components/database/components/filters/Filter.tsx new file mode 100644 index 0000000000..3fe0c4daf3 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/Filter.tsx @@ -0,0 +1,57 @@ +import { useFilterSelector } from '@/application/database-yjs'; +import { Popover } from '@/components/_shared/popover'; +import { FilterContentOverview } from './overview'; +import React, { useState } from 'react'; +import { FieldDisplay } from '@/components/database/components/field'; +import { ReactComponent as ArrowDownSvg } from '$icons/16x/arrow_down.svg'; +import { FilterMenu } from './filter-menu'; + +function Filter({ filterId }: { filterId: string }) { + const filter = useFilterSelector(filterId); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + if (!filter) return null; + + return ( + <> +
{ + setAnchorEl(e.currentTarget); + }} + className={ + 'flex cursor-pointer flex-nowrap items-center gap-1 rounded-full border border-line-divider py-1 px-2 hover:border-fill-default hover:text-fill-default hover:shadow-sm' + } + > +
+ +
+ +
+ +
+ +
+ {open && ( + { + setAnchorEl(null); + }} + slotProps={{ + paper: { + style: { + maxHeight: '260px', + }, + }, + }} + > + + + )} + + ); +} + +export default Filter; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/Filters.tsx b/frontend/appflowy_web_app/src/components/database/components/filters/Filters.tsx new file mode 100644 index 0000000000..41f54f8cac --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/Filters.tsx @@ -0,0 +1,32 @@ +import { useFiltersSelector, useReadOnly } from '@/application/database-yjs'; +import Filter from '@/components/database/components/filters/Filter'; +import Button from '@mui/material/Button'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as AddFilterSvg } from '$icons/16x/add.svg'; + +export function Filters() { + const filters = useFiltersSelector(); + const { t } = useTranslation(); + const readOnly = useReadOnly(); + + return ( + <> + {filters.map((filterId) => ( + + ))} + + + ); +} + +export default Filters; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/CheckboxFilterMenu.tsx b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/CheckboxFilterMenu.tsx new file mode 100644 index 0000000000..851e811499 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/CheckboxFilterMenu.tsx @@ -0,0 +1,33 @@ +import { CheckboxFilter, CheckboxFilterCondition } from '@/application/database-yjs'; +import FieldMenuTitle from '@/components/database/components/filters/filter-menu/FieldMenuTitle'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +function CheckboxFilterMenu({ filter }: { filter: CheckboxFilter }) { + const { t } = useTranslation(); + + const conditions = useMemo( + () => [ + { + value: CheckboxFilterCondition.IsChecked, + text: t('grid.checkboxFilter.isChecked'), + }, + { + value: CheckboxFilterCondition.IsUnChecked, + text: t('grid.checkboxFilter.isUnchecked'), + }, + ], + [t] + ); + const selectedCondition = useMemo(() => { + return conditions.find((c) => c.value === filter.condition); + }, [filter.condition, conditions]); + + return ( +
+ +
+ ); +} + +export default CheckboxFilterMenu; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/ChecklistFilterMenu.tsx b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/ChecklistFilterMenu.tsx new file mode 100644 index 0000000000..5d6398b242 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/ChecklistFilterMenu.tsx @@ -0,0 +1,33 @@ +import { ChecklistFilter, ChecklistFilterCondition } from '@/application/database-yjs'; +import FieldMenuTitle from '@/components/database/components/filters/filter-menu/FieldMenuTitle'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +function ChecklistFilterMenu({ filter }: { filter: ChecklistFilter }) { + const { t } = useTranslation(); + + const conditions = useMemo( + () => [ + { + value: ChecklistFilterCondition.IsComplete, + text: t('grid.checklistFilter.isComplete'), + }, + { + value: ChecklistFilterCondition.IsIncomplete, + text: t('grid.checklistFilter.isIncomplted'), + }, + ], + [t] + ); + const selectedCondition = useMemo(() => { + return conditions.find((c) => c.value === filter.condition); + }, [filter.condition, conditions]); + + return ( +
+ +
+ ); +} + +export default ChecklistFilterMenu; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/FieldMenuTitle.tsx b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/FieldMenuTitle.tsx new file mode 100644 index 0000000000..e5784b44f5 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/FieldMenuTitle.tsx @@ -0,0 +1,23 @@ +import { ReactComponent as ArrowDownSvg } from '$icons/16x/arrow_down.svg'; +import { FieldDisplay } from '@/components/database/components/field'; +import React from 'react'; + +function FieldMenuTitle({ fieldId, selectedConditionText }: { fieldId: string; selectedConditionText: string }) { + return ( +
+
+ +
+
+
+
+ {selectedConditionText} +
+ +
+
+
+ ); +} + +export default FieldMenuTitle; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/FilterMenu.tsx b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/FilterMenu.tsx new file mode 100644 index 0000000000..720dac3d3d --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/FilterMenu.tsx @@ -0,0 +1,39 @@ +import { YjsDatabaseKey } from '@/application/collab.type'; +import { FieldType, Filter, SelectOptionFilter, useFieldSelector } from '@/application/database-yjs'; +import CheckboxFilterMenu from './CheckboxFilterMenu'; +import ChecklistFilterMenu from './ChecklistFilterMenu'; +import MultiSelectOptionFilterMenu from './MultiSelectOptionFilterMenu'; +import NumberFilterMenu from './NumberFilterMenu'; +import SingleSelectOptionFilterMenu from './SingleSelectOptionFilterMenu'; +import TextFilterMenu from './TextFilterMenu'; +import React, { useMemo } from 'react'; + +export function FilterMenu({ filter }: { filter: Filter }) { + const { field } = useFieldSelector(filter?.fieldId); + const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType; + + const menu = useMemo(() => { + if (!field) return null; + switch (fieldType) { + case FieldType.RichText: + case FieldType.URL: + return ; + case FieldType.Checkbox: + return ; + case FieldType.Checklist: + return ; + case FieldType.Number: + return ; + case FieldType.MultiSelect: + return ; + case FieldType.SingleSelect: + return ; + default: + return null; + } + }, [field, fieldType, filter]); + + return menu; +} + +export default FilterMenu; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/MultiSelectOptionFilterMenu.tsx b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/MultiSelectOptionFilterMenu.tsx new file mode 100644 index 0000000000..68def09bb8 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/MultiSelectOptionFilterMenu.tsx @@ -0,0 +1,56 @@ +import { SelectOptionFilter, SelectOptionFilterCondition } from '@/application/database-yjs'; +import { SelectOptionList } from '@/components/database/components/field/select-option'; +import FieldMenuTitle from './FieldMenuTitle'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +function MultiSelectOptionFilterMenu({ filter }: { filter: SelectOptionFilter }) { + const { t } = useTranslation(); + const conditions = useMemo(() => { + return [ + { + value: SelectOptionFilterCondition.OptionIs, + text: t('grid.selectOptionFilter.is'), + }, + { + value: SelectOptionFilterCondition.OptionIsNot, + text: t('grid.selectOptionFilter.isNot'), + }, + { + value: SelectOptionFilterCondition.OptionContains, + text: t('grid.selectOptionFilter.contains'), + }, + { + value: SelectOptionFilterCondition.OptionDoesNotContain, + text: t('grid.selectOptionFilter.doesNotContain'), + }, + { + value: SelectOptionFilterCondition.OptionIsEmpty, + text: t('grid.selectOptionFilter.isEmpty'), + }, + { + value: SelectOptionFilterCondition.OptionIsNotEmpty, + text: t('grid.selectOptionFilter.isNotEmpty'), + }, + ]; + }, [t]); + + const selectedCondition = useMemo(() => { + return conditions.find((c) => c.value === filter.condition); + }, [filter.condition, conditions]); + + const displaySelectOptionList = useMemo(() => { + return ![SelectOptionFilterCondition.OptionIsEmpty, SelectOptionFilterCondition.OptionIsNotEmpty].includes( + filter.condition + ); + }, [filter.condition]); + + return ( +
+ + {displaySelectOptionList && } +
+ ); +} + +export default MultiSelectOptionFilterMenu; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/NumberFilterMenu.tsx b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/NumberFilterMenu.tsx new file mode 100644 index 0000000000..fdd8963ef2 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/NumberFilterMenu.tsx @@ -0,0 +1,74 @@ +import { NumberFilter, NumberFilterCondition, useReadOnly } from '@/application/database-yjs'; +import FieldMenuTitle from '@/components/database/components/filters/filter-menu/FieldMenuTitle'; +import { TextField } from '@mui/material'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +function NumberFilterMenu({ filter }: { filter: NumberFilter }) { + const { t } = useTranslation(); + const readOnly = useReadOnly(); + const conditions = useMemo(() => { + return [ + { + value: NumberFilterCondition.Equal, + text: t('grid.numberFilter.equal'), + }, + { + value: NumberFilterCondition.NotEqual, + text: t('grid.numberFilter.notEqual'), + }, + { + value: NumberFilterCondition.GreaterThan, + text: t('grid.numberFilter.greaterThan'), + }, + { + value: NumberFilterCondition.LessThan, + text: t('grid.numberFilter.lessThan'), + }, + { + value: NumberFilterCondition.GreaterThanOrEqualTo, + text: t('grid.numberFilter.greaterThanOrEqualTo'), + }, + { + value: NumberFilterCondition.LessThanOrEqualTo, + text: t('grid.numberFilter.lessThanOrEqualTo'), + }, + { + value: NumberFilterCondition.NumberIsEmpty, + text: t('grid.textFilter.isEmpty'), + }, + { + value: NumberFilterCondition.NumberIsNotEmpty, + text: t('grid.textFilter.isNotEmpty'), + }, + ]; + }, [t]); + + const selectedCondition = useMemo(() => { + return conditions.find((c) => c.value === filter.condition); + }, [filter.condition, conditions]); + + const displayTextField = useMemo(() => { + return ![NumberFilterCondition.NumberIsEmpty, NumberFilterCondition.NumberIsNotEmpty].includes(filter.condition); + }, [filter.condition]); + + return ( +
+ + {displayTextField && ( + + )} +
+ ); +} + +export default NumberFilterMenu; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/SingleSelectOptionFilterMenu.tsx b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/SingleSelectOptionFilterMenu.tsx new file mode 100644 index 0000000000..217ad8d1ae --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/SingleSelectOptionFilterMenu.tsx @@ -0,0 +1,48 @@ +import { SelectOptionFilter, SelectOptionFilterCondition } from '@/application/database-yjs'; +import { SelectOptionList } from '@/components/database/components/field/select-option'; +import FieldMenuTitle from '@/components/database/components/filters/filter-menu/FieldMenuTitle'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +function SingleSelectOptionFilterMenu({ filter }: { filter: SelectOptionFilter }) { + const { t } = useTranslation(); + const conditions = useMemo(() => { + return [ + { + value: SelectOptionFilterCondition.OptionIs, + text: t('grid.selectOptionFilter.is'), + }, + { + value: SelectOptionFilterCondition.OptionIsNot, + text: t('grid.selectOptionFilter.isNot'), + }, + { + value: SelectOptionFilterCondition.OptionIsEmpty, + text: t('grid.selectOptionFilter.isEmpty'), + }, + { + value: SelectOptionFilterCondition.OptionIsNotEmpty, + text: t('grid.selectOptionFilter.isNotEmpty'), + }, + ]; + }, [t]); + + const selectedCondition = useMemo(() => { + return conditions.find((c) => c.value === filter.condition); + }, [filter.condition, conditions]); + + const displaySelectOptionList = useMemo(() => { + return ![SelectOptionFilterCondition.OptionIsEmpty, SelectOptionFilterCondition.OptionIsNotEmpty].includes( + filter.condition + ); + }, [filter.condition]); + + return ( +
+ + {displaySelectOptionList && } +
+ ); +} + +export default SingleSelectOptionFilterMenu; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/TextFilterMenu.tsx b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/TextFilterMenu.tsx new file mode 100644 index 0000000000..f3ca7690af --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/TextFilterMenu.tsx @@ -0,0 +1,74 @@ +import { TextFilter, TextFilterCondition, useReadOnly } from '@/application/database-yjs'; +import FieldMenuTitle from '@/components/database/components/filters/filter-menu/FieldMenuTitle'; +import { TextField } from '@mui/material'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +function TextFilterMenu({ filter }: { filter: TextFilter }) { + const { t } = useTranslation(); + const readOnly = useReadOnly(); + const conditions = useMemo(() => { + return [ + { + value: TextFilterCondition.TextContains, + text: t('grid.textFilter.contains'), + }, + { + value: TextFilterCondition.TextDoesNotContain, + text: t('grid.textFilter.doesNotContain'), + }, + { + value: TextFilterCondition.TextStartsWith, + text: t('grid.textFilter.startWith'), + }, + { + value: TextFilterCondition.TextEndsWith, + text: t('grid.textFilter.endsWith'), + }, + { + value: TextFilterCondition.TextIs, + text: t('grid.textFilter.is'), + }, + { + value: TextFilterCondition.TextIsNot, + text: t('grid.textFilter.isNot'), + }, + { + value: TextFilterCondition.TextIsEmpty, + text: t('grid.textFilter.isEmpty'), + }, + { + value: TextFilterCondition.TextIsNotEmpty, + text: t('grid.textFilter.isNotEmpty'), + }, + ]; + }, [t]); + + const selectedCondition = useMemo(() => { + return conditions.find((c) => c.value === filter.condition); + }, [filter.condition, conditions]); + + const displayTextField = useMemo(() => { + return ![TextFilterCondition.TextIsEmpty, TextFilterCondition.TextIsNotEmpty].includes(filter.condition); + }, [filter.condition]); + + return ( +
+ + {displayTextField && ( + + )} +
+ ); +} + +export default TextFilterMenu; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/index.ts b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/index.ts new file mode 100644 index 0000000000..fc54ea0f3a --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/filter-menu/index.ts @@ -0,0 +1 @@ +export * from './FilterMenu'; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/index.ts b/frontend/appflowy_web_app/src/components/database/components/filters/index.ts new file mode 100644 index 0000000000..c7b59bcd2f --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/index.ts @@ -0,0 +1 @@ +export * from './Filters'; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/overview/DateFilterContentOverview.tsx b/frontend/appflowy_web_app/src/components/database/components/filters/overview/DateFilterContentOverview.tsx new file mode 100644 index 0000000000..d3a30e1844 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/overview/DateFilterContentOverview.tsx @@ -0,0 +1,51 @@ +import { DateFilter, DateFilterCondition } from '@/application/database-yjs'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import dayjs from 'dayjs'; + +function DateFilterContentOverview({ filter }: { filter: DateFilter }) { + const { t } = useTranslation(); + + const value = useMemo(() => { + if (!filter.timestamp) return ''; + + let startStr = ''; + let endStr = ''; + + if (filter.start) { + const end = filter.end ?? filter.start; + const moreThanOneYear = dayjs.unix(end).diff(dayjs.unix(filter.start), 'year') > 1; + const format = moreThanOneYear ? 'MMM D, YYYY' : 'MMM D'; + + startStr = dayjs.unix(filter.start).format(format); + endStr = dayjs.unix(end).format(format); + } + + const timestamp = dayjs.unix(filter.timestamp).format('MMM D'); + + switch (filter.condition) { + case DateFilterCondition.DateIs: + return `: ${timestamp}`; + case DateFilterCondition.DateBefore: + return `: ${t('grid.dateFilter.choicechipPrefix.before')} ${timestamp}`; + case DateFilterCondition.DateAfter: + return `: ${t('grid.dateFilter.choicechipPrefix.after')} ${timestamp}`; + case DateFilterCondition.DateOnOrBefore: + return `: ${t('grid.dateFilter.choicechipPrefix.onOrBefore')} ${timestamp}`; + case DateFilterCondition.DateOnOrAfter: + return `: ${t('grid.dateFilter.choicechipPrefix.onOrAfter')} ${timestamp}`; + case DateFilterCondition.DateWithIn: + return `: ${startStr} - ${endStr}`; + case DateFilterCondition.DateIsEmpty: + return `: ${t('grid.dateFilter.choicechipPrefix.isEmpty')}`; + case DateFilterCondition.DateIsNotEmpty: + return `: ${t('grid.dateFilter.choicechipPrefix.isNotEmpty')}`; + default: + return ''; + } + }, [filter, t]); + + return <>{value}; +} + +export default DateFilterContentOverview; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/overview/FilterContentOverview.tsx b/frontend/appflowy_web_app/src/components/database/components/filters/overview/FilterContentOverview.tsx new file mode 100644 index 0000000000..9f6d1ea188 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/overview/FilterContentOverview.tsx @@ -0,0 +1,59 @@ +import { YjsDatabaseKey } from '@/application/collab.type'; +import { + CheckboxFilterCondition, + ChecklistFilterCondition, + FieldType, + Filter, + SelectOptionFilter, + useFieldSelector, +} from '@/application/database-yjs'; +import DateFilterContentOverview from '@/components/database/components/filters/overview/DateFilterContentOverview'; +import NumberFilterContentOverview from '@/components/database/components/filters/overview/NumberFilterContentOverview'; +import SelectFilterContentOverview from '@/components/database/components/filters/overview/SelectFilterContentOverview'; +import TextFilterContentOverview from '@/components/database/components/filters/overview/TextFilterContentOverview'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +export function FilterContentOverview({ filter }: { filter: Filter }) { + const { field } = useFieldSelector(filter?.fieldId); + const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType; + const { t } = useTranslation(); + + return useMemo(() => { + if (!field) return null; + switch (fieldType) { + case FieldType.RichText: + case FieldType.URL: + return ; + case FieldType.Number: + return ; + case FieldType.DateTime: + case FieldType.LastEditedTime: + case FieldType.CreatedTime: + return ; + case FieldType.SingleSelect: + case FieldType.MultiSelect: + return ; + case FieldType.Checkbox: + return ( + <> + : {t('grid.checkboxFilter.choicechipPrefix.is')}{' '} + {filter.condition === CheckboxFilterCondition.IsChecked + ? t('grid.checkboxFilter.isChecked') + : t('grid.checkboxFilter.isUnchecked')} + + ); + case FieldType.Checklist: + return ( + <> + :{' '} + {filter.condition === ChecklistFilterCondition.IsComplete + ? t('grid.checklistFilter.isComplete') + : t('grid.checklistFilter.isIncomplted')} + + ); + default: + return null; + } + }, [field, fieldType, filter, t]); +} diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/overview/NumberFilterContentOverview.tsx b/frontend/appflowy_web_app/src/components/database/components/filters/overview/NumberFilterContentOverview.tsx new file mode 100644 index 0000000000..64864541e7 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/overview/NumberFilterContentOverview.tsx @@ -0,0 +1,38 @@ +import { NumberFilter, NumberFilterCondition } from '@/application/database-yjs'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +function NumberFilterContentOverview({ filter }: { filter: NumberFilter }) { + const { t } = useTranslation(); + + const value = useMemo(() => { + if (!filter.content) { + return ''; + } + + const content = parseInt(filter.content); + + switch (filter.condition) { + case NumberFilterCondition.Equal: + return `= ${content}`; + case NumberFilterCondition.NotEqual: + return `!= ${content}`; + case NumberFilterCondition.GreaterThan: + return `> ${content}`; + case NumberFilterCondition.GreaterThanOrEqualTo: + return `>= ${content}`; + case NumberFilterCondition.LessThan: + return `< ${content}`; + case NumberFilterCondition.LessThanOrEqualTo: + return `<= ${content}`; + case NumberFilterCondition.NumberIsEmpty: + return t('grid.textFilter.isEmpty'); + case NumberFilterCondition.NumberIsNotEmpty: + return t('grid.textFilter.isNotEmpty'); + } + }, [filter.condition, filter.content, t]); + + return <>{value}; +} + +export default NumberFilterContentOverview; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/overview/SelectFilterContentOverview.tsx b/frontend/appflowy_web_app/src/components/database/components/filters/overview/SelectFilterContentOverview.tsx new file mode 100644 index 0000000000..64e8ddc00c --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/overview/SelectFilterContentOverview.tsx @@ -0,0 +1,42 @@ +import { YDatabaseField } from '@/application/collab.type'; +import { + parseSelectOptionTypeOptions, + SelectOptionFilter, + SelectOptionFilterCondition, +} from '@/application/database-yjs'; +import React, { useMemo } from 'react'; + +import { useTranslation } from 'react-i18next'; + +function SelectFilterContentOverview({ filter, field }: { filter: SelectOptionFilter; field: YDatabaseField }) { + const typeOption = parseSelectOptionTypeOptions(field); + const { t } = useTranslation(); + const value = useMemo(() => { + if (!filter.optionIds?.length) return ''; + + const options = filter.optionIds + .map((optionId) => { + const option = typeOption?.options?.find((option) => option.id === optionId); + + return option?.name; + }) + .join(', '); + + switch (filter.condition) { + case SelectOptionFilterCondition.OptionIs: + return `: ${options}`; + case SelectOptionFilterCondition.OptionIsNot: + return `: ${t('grid.textFilter.choicechipPrefix.isNot')} ${options}`; + case SelectOptionFilterCondition.OptionIsEmpty: + return `: ${t('grid.textFilter.choicechipPrefix.isEmpty')}`; + case SelectOptionFilterCondition.OptionIsNotEmpty: + return `: ${t('grid.textFilter.choicechipPrefix.isNotEmpty')}`; + default: + return ''; + } + }, [filter.condition, filter.optionIds, t, typeOption?.options]); + + return <>{value}; +} + +export default SelectFilterContentOverview; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/overview/TextFilterContentOverview.tsx b/frontend/appflowy_web_app/src/components/database/components/filters/overview/TextFilterContentOverview.tsx new file mode 100644 index 0000000000..fc03b39c96 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/overview/TextFilterContentOverview.tsx @@ -0,0 +1,33 @@ +import { TextFilter, TextFilterCondition } from '@/application/database-yjs'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +function TextFilterContentOverview({ filter }: { filter: TextFilter }) { + const { t } = useTranslation(); + + const value = useMemo(() => { + if (!filter.content) return ''; + switch (filter.condition) { + case TextFilterCondition.TextContains: + case TextFilterCondition.TextIs: + return `: ${filter.content}`; + case TextFilterCondition.TextDoesNotContain: + case TextFilterCondition.TextIsNot: + return `: ${t('grid.textFilter.choicechipPrefix.isNot')} ${filter.content}`; + case TextFilterCondition.TextStartsWith: + return `: ${t('grid.textFilter.choicechipPrefix.startWith')} ${filter.content}`; + case TextFilterCondition.TextEndsWith: + return `: ${t('grid.textFilter.choicechipPrefix.endWith')} ${filter.content}`; + case TextFilterCondition.TextIsEmpty: + return `: ${t('grid.textFilter.choicechipPrefix.isEmpty')}`; + case TextFilterCondition.TextIsNotEmpty: + return `: ${t('grid.textFilter.choicechipPrefix.isNotEmpty')}`; + default: + return ''; + } + }, [t, filter]); + + return <>{value}; +} + +export default TextFilterContentOverview; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/overview/index.ts b/frontend/appflowy_web_app/src/components/database/components/filters/overview/index.ts new file mode 100644 index 0000000000..47e041409e --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/overview/index.ts @@ -0,0 +1 @@ +export * from './FilterContentOverview'; diff --git a/frontend/appflowy_web_app/src/components/database/components/filters/package.json b/frontend/appflowy_web_app/src/components/database/components/filters/package.json new file mode 100644 index 0000000000..e56f3198c9 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/filters/package.json @@ -0,0 +1,14 @@ +{ + "name": "filters", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "https://github.com/qinluhe/AppFlowy.git" + }, + "private": true +} diff --git a/frontend/appflowy_web_app/src/components/database/components/grid-cell/GridCell.tsx b/frontend/appflowy_web_app/src/components/database/components/grid-cell/GridCell.tsx new file mode 100644 index 0000000000..b9a5017b38 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/grid-cell/GridCell.tsx @@ -0,0 +1,64 @@ +import { FieldId, YjsDatabaseKey } from '@/application/collab.type'; +import { useRowMeta } from '@/application/database-yjs'; +import { useFieldSelector } from '@/application/database-yjs/selector'; +import { Cell } from '@/components/database/components/cell'; +import { parseYDatabaseCellToCell } from '@/components/database/components/cell/cell.parse'; +import React, { useEffect, useState } from 'react'; + +export interface GridCellProps { + rowId: string; + fieldId: FieldId; + columnIndex: number; + rowIndex: number; + onResize?: (rowIndex: number, columnIndex: number, size: { width: number; height: number }) => void; +} + +export function GridCell({ onResize, rowId, fieldId, columnIndex, rowIndex }: GridCellProps) { + const ref = React.useRef(null); + const field = useFieldSelector(fieldId); + const row = useRowMeta(rowId); + const cell = row?.get(YjsDatabaseKey.cells)?.get(fieldId); + const [cellValue, setCellValue] = useState(() => (cell ? parseYDatabaseCellToCell(cell) : undefined)); + + useEffect(() => { + if (!cell) return; + setCellValue(parseYDatabaseCellToCell(cell)); + const observerEvent = () => setCellValue(parseYDatabaseCellToCell(cell)); + + cell.observe(observerEvent); + + return () => { + cell.unobserve(observerEvent); + }; + }, [cell]); + + useEffect(() => { + const el = ref.current; + + if (!el) return; + + const observer = new ResizeObserver(() => { + if (onResize) { + onResize(rowIndex, columnIndex, { + width: el.offsetWidth, + height: el.offsetHeight, + }); + } + }); + + observer.observe(el); + + return () => { + observer.disconnect(); + }; + }, [columnIndex, onResize, rowIndex]); + + if (!field) return null; + return ( +
+ +
+ ); +} + +export default GridCell; diff --git a/frontend/appflowy_web_app/src/components/database/components/grid-cell/index.ts b/frontend/appflowy_web_app/src/components/database/components/grid-cell/index.ts new file mode 100644 index 0000000000..2b6d663ef5 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/grid-cell/index.ts @@ -0,0 +1 @@ +export * from './GridCell'; diff --git a/frontend/appflowy_web_app/src/components/database/components/grid-column/GridColumn.tsx b/frontend/appflowy_web_app/src/components/database/components/grid-column/GridColumn.tsx new file mode 100644 index 0000000000..88c6fae84e --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/grid-column/GridColumn.tsx @@ -0,0 +1,35 @@ +import { YjsDatabaseKey } from '@/application/collab.type'; +import { FieldType } from '@/application/database-yjs/database.type'; +import { Column, useFieldSelector } from '@/application/database-yjs/selector'; +import { FieldTypeIcon } from '@/components/database/components/field'; +import React, { useMemo } from 'react'; + +export function GridColumn({ column, index }: { column: Column; index: number }) { + const { field } = useFieldSelector(column.fieldId); + const name = field?.get(YjsDatabaseKey.name); + const type = useMemo(() => { + const type = field?.get(YjsDatabaseKey.type); + + if (!type) return FieldType.RichText; + + return parseInt(type) as FieldType; + }, [field]); + + return ( +
+
+ +
+
{name}
+
+ ); +} + +export default GridColumn; diff --git a/frontend/appflowy_web_app/src/components/database/components/grid-column/index.ts b/frontend/appflowy_web_app/src/components/database/components/grid-column/index.ts new file mode 100644 index 0000000000..6de83c7026 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/grid-column/index.ts @@ -0,0 +1,2 @@ +export * from './GridColumn'; +export * from './useRenderColumns'; diff --git a/frontend/appflowy_web_app/src/components/database/components/grid-column/useRenderColumns.tsx b/frontend/appflowy_web_app/src/components/database/components/grid-column/useRenderColumns.tsx new file mode 100644 index 0000000000..c0041b5c5e --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/grid-column/useRenderColumns.tsx @@ -0,0 +1,73 @@ +import { FieldId } from '@/application/collab.type'; +import { FieldVisibility } from '@/application/database-yjs/database.type'; +import { useGridColumnsSelector } from '@/application/database-yjs/selector'; +import { useCallback, useMemo } from 'react'; + +export enum GridColumnType { + Action, + Field, + NewProperty, +} + +const defaultVisibilitys = [FieldVisibility.AlwaysShown, FieldVisibility.HideWhenEmpty]; + +export type RenderColumn = { + type: GridColumnType; + visibility?: FieldVisibility; + fieldId?: FieldId; + width: number; + wrap?: boolean; +}; + +export function useRenderColumns(viewId: string) { + const columns = useGridColumnsSelector(viewId, defaultVisibilitys); + + console.log('columns', columns); + const renderColumns = useMemo(() => { + const fields = columns.map((column) => ({ + ...column, + type: GridColumnType.Field, + })); + + return [ + { + type: GridColumnType.Action, + width: 96, + }, + ...fields, + { + type: GridColumnType.NewProperty, + width: 150, + }, + { + type: GridColumnType.Action, + width: 96, + }, + ].filter(Boolean) as RenderColumn[]; + }, [columns]); + + const columnWidth = useCallback( + (index: number, containerWidth: number) => { + const { type, width } = renderColumns[index]; + + if (type === GridColumnType.NewProperty) { + const totalWidth = renderColumns.reduce((acc, column) => acc + column.width, 0); + const remainingWidth = containerWidth - totalWidth; + + return remainingWidth > 0 ? remainingWidth + width : width; + } + + if (type === GridColumnType.Action && containerWidth < 800) { + return 16; + } + + return width; + }, + [renderColumns] + ); + + return { + columns: renderColumns, + columnWidth, + }; +} diff --git a/frontend/appflowy_web_app/src/components/database/components/grid-header/GridHeader.tsx b/frontend/appflowy_web_app/src/components/database/components/grid-header/GridHeader.tsx new file mode 100644 index 0000000000..64d0c39117 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/grid-header/GridHeader.tsx @@ -0,0 +1,73 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { GridChildComponentProps, VariableSizeGrid } from 'react-window'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { GridColumnType, RenderColumn, GridColumn } from '../grid-column'; + +export interface GridHeaderProps { + onScrollLeft: (left: number) => void; + columnWidth: (index: number, totalWidth: number) => number; + columns: RenderColumn[]; + scrollLeft?: number; +} + +export const GridHeader = ({ scrollLeft, onScrollLeft, columnWidth, columns }: GridHeaderProps) => { + const ref = useRef(null); + const Cell = useCallback(({ columnIndex, style, data }: GridChildComponentProps) => { + const column = data[columnIndex]; + + // Placeholder for Action toolbar + if (!column || column.type === GridColumnType.Action) return
; + + if (column.type === GridColumnType.Field) { + return ( +
+ +
+ ); + } + + return
; + }, []); + + useEffect(() => { + if (ref.current) { + ref.current.scrollTo({ scrollLeft }); + } + }, [scrollLeft]); + + useEffect(() => { + if (ref.current) { + ref.current?.resetAfterIndices({ columnIndex: 0, rowIndex: 0 }); + } + }, [columns]); + + return ( +
+ + {({ height, width }: { height: number; width: number }) => { + return ( + 36} + rowCount={1} + columnCount={columns.length} + columnWidth={(index) => columnWidth(index, width)} + ref={ref} + onScroll={(props) => { + onScrollLeft(props.scrollLeft); + }} + itemData={columns} + style={{ overscrollBehavior: 'none' }} + > + {Cell} + + ); + }} + +
+ ); +}; + +export default GridHeader; diff --git a/frontend/appflowy_web_app/src/components/database/components/grid-header/index.ts b/frontend/appflowy_web_app/src/components/database/components/grid-header/index.ts new file mode 100644 index 0000000000..44d8082bd7 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/grid-header/index.ts @@ -0,0 +1 @@ +export * from './GridHeader'; diff --git a/frontend/appflowy_web_app/src/components/database/components/grid-row/GridCalculateRowCell.tsx b/frontend/appflowy_web_app/src/components/database/components/grid-row/GridCalculateRowCell.tsx new file mode 100644 index 0000000000..650ed3bfbe --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/grid-row/GridCalculateRowCell.tsx @@ -0,0 +1,41 @@ +import { YjsDatabaseKey } from '@/application/collab.type'; +import { useDatabaseView } from '@/application/database-yjs'; +import { CalculationType } from '@/application/database-yjs/database.type'; +import { CalculationCell } from '@/components/database/components/calculation-cell'; +import { CalulationCell } from '@/components/database/components/calculation-cell/cell.type'; +import React, { useEffect, useState } from 'react'; + +export interface GridCalculateRowCellProps { + fieldId: string; +} + +export function GridCalculateRowCell({ fieldId }: GridCalculateRowCellProps) { + const calculations = useDatabaseView()?.get(YjsDatabaseKey.calculations); + const [calculation, setCalculation] = useState(); + + useEffect(() => { + if (!calculations) return; + const observerHandle = () => { + calculations.forEach((calculation) => { + if (calculation.get(YjsDatabaseKey.field_id) === fieldId) { + setCalculation({ + id: calculation.get(YjsDatabaseKey.id), + fieldId: calculation.get(YjsDatabaseKey.field_id), + value: calculation.get(YjsDatabaseKey.calculation_value), + type: Number(calculation.get(YjsDatabaseKey.type)) as CalculationType, + }); + } + }); + }; + + observerHandle(); + calculations.observeDeep(observerHandle); + + return () => { + calculations.unobserveDeep(observerHandle); + }; + }, [calculations, fieldId]); + return ; +} + +export default GridCalculateRowCell; diff --git a/frontend/appflowy_web_app/src/components/database/components/grid-row/GridRowCell.tsx b/frontend/appflowy_web_app/src/components/database/components/grid-row/GridRowCell.tsx new file mode 100644 index 0000000000..ef4be68406 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/grid-row/GridRowCell.tsx @@ -0,0 +1,28 @@ +import { GridColumnType } from '@/components/database/components/grid-column'; +import React from 'react'; +import GridCell from 'src/components/database/components/grid-cell/GridCell'; + +export interface GridRowCellProps { + rowId: string; + fieldId?: string; + type: GridColumnType; + columnIndex: number; + rowIndex: number; + onResize?: (rowIndex: number, columnIndex: number, size: { width: number; height: number }) => void; +} + +export function GridRowCell({ onResize, rowIndex, columnIndex, rowId, fieldId, type }: GridRowCellProps) { + if (type === GridColumnType.Field && fieldId) { + return ( + + ); + } + + if (type === GridColumnType.Action) { + return null; + } + + return null; +} + +export default GridRowCell; diff --git a/frontend/appflowy_web_app/src/components/database/components/grid-row/index.ts b/frontend/appflowy_web_app/src/components/database/components/grid-row/index.ts new file mode 100644 index 0000000000..365c3f467e --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/grid-row/index.ts @@ -0,0 +1,3 @@ +export * from './GridCalculateRowCell'; +export * from './GridRowCell'; +export * from './useRenderRows'; diff --git a/frontend/appflowy_web_app/src/components/database/components/grid-row/useRenderRows.tsx b/frontend/appflowy_web_app/src/components/database/components/grid-row/useRenderRows.tsx new file mode 100644 index 0000000000..e5038cafff --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/grid-row/useRenderRows.tsx @@ -0,0 +1,44 @@ +import { useReadOnly } from '@/application/database-yjs'; +import { DEFAULT_ROW_HEIGHT } from '@/application/database-yjs/const'; +import { useGridRowsSelector } from '@/application/database-yjs/selector'; +import { useMemo } from 'react'; + +export enum RenderRowType { + Row = 'row', + NewRow = 'new-row', + CalculateRow = 'calculate-row', +} + +export type RenderRow = { + type: RenderRowType; + rowId?: string; + height?: number; +}; + +export function useRenderRows() { + const rows = useGridRowsSelector(); + const readOnly = useReadOnly(); + + const renderRows = useMemo(() => { + return [ + ...rows.map((row) => ({ + type: RenderRowType.Row, + rowId: row.id, + height: row.height, + })), + + !readOnly && { + type: RenderRowType.NewRow, + height: DEFAULT_ROW_HEIGHT, + }, + { + type: RenderRowType.CalculateRow, + height: DEFAULT_ROW_HEIGHT, + }, + ].filter(Boolean) as RenderRow[]; + }, [readOnly, rows]); + + return { + rows: renderRows, + }; +} diff --git a/frontend/appflowy_web_app/src/components/database/components/grid-table/GridTable.tsx b/frontend/appflowy_web_app/src/components/database/components/grid-table/GridTable.tsx new file mode 100644 index 0000000000..dd3ed13bfe --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/grid-table/GridTable.tsx @@ -0,0 +1,177 @@ +import { DEFAULT_ROW_HEIGHT } from '@/application/database-yjs/const'; +import { AFScroller } from '@/components/_shared/scroller'; +import { GridColumnType, RenderColumn } from '@/components/database/components/grid-column'; +import { + GridCalculateRowCell, + GridRowCell, + RenderRowType, + useRenderRows, +} from '@/components/database/components/grid-row'; +import React, { useCallback, useEffect, useRef } from 'react'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { GridChildComponentProps, VariableSizeGrid } from 'react-window'; + +export interface GridTableProps { + onScrollLeft: (left: number) => void; + columnWidth: (index: number, totalWidth: number) => number; + + columns: RenderColumn[]; + scrollLeft?: number; + viewId: string; +} + +export const GridTable = ({ scrollLeft, columnWidth, columns, onScrollLeft }: GridTableProps) => { + const ref = useRef(null); + const { rows } = useRenderRows(); + const rowHeights = useRef<{ [key: string]: number }>({}); + + useEffect(() => { + if (ref.current) { + console.log(ref.current, scrollLeft); + ref.current.scrollTo({ scrollLeft }); + } + }, [scrollLeft]); + + useEffect(() => { + if (ref.current) { + ref.current.resetAfterIndices({ columnIndex: 0, rowIndex: 0 }); + } + }, [columns]); + + const rowHeight = useCallback( + (index: number) => { + const row = rows[index]; + + if (!row || !row.rowId) return DEFAULT_ROW_HEIGHT; + + return rowHeights.current[row.rowId] || DEFAULT_ROW_HEIGHT; + }, + [rows] + ); + + const setRowHeight = useCallback( + (index: number, height: number) => { + const row = rows[index]; + const rowId = row.rowId; + + if (!row || !rowId) return; + const oldHeight = rowHeights.current[rowId]; + + rowHeights.current[rowId] = Math.max(oldHeight || DEFAULT_ROW_HEIGHT, height); + if (oldHeight !== height) { + ref.current?.resetAfterRowIndex(index, true); + } + }, + [rows] + ); + + const onResize = useCallback( + (rowIndex: number, columnIndex: number, size: { width: number; height: number }) => { + setRowHeight(rowIndex, size.height); + }, + [setRowHeight] + ); + + const getItemKey = useCallback( + ({ columnIndex, rowIndex }: { columnIndex: number; rowIndex: number }) => { + const row = rows[rowIndex]; + const column = columns[columnIndex]; + const fieldId = column.fieldId; + + if (row.type === RenderRowType.Row) { + if (fieldId) { + return `${row.rowId}:${fieldId}`; + } + + return `${rowIndex}:${columnIndex}`; + } + + if (fieldId) { + return `${row.type}:${fieldId}`; + } + + return `${rowIndex}:${columnIndex}`; + }, + [columns, rows] + ); + const Cell = useCallback( + ({ columnIndex, rowIndex, style, data }: GridChildComponentProps) => { + const row = data.rows[rowIndex]; + const column = data.columns[columnIndex] as RenderColumn; + + const classList = ['flex', 'items-center', 'overflow-hidden']; + + if (column.wrap) { + classList.push('whitespace-pre-wrap', 'break-words'); + } else { + classList.push('whitespace-nowrap'); + } + + if (column.type === GridColumnType.Field) { + classList.push('border-b', 'border-l', 'border-line-divider', 'px-2'); + } + + if (column.type === GridColumnType.NewProperty) { + classList.push('border-b', 'border-line-divider', 'px-2'); + } + + if (row.type === RenderRowType.Row) { + return ( +
+ +
+ ); + } + + if (row.type === RenderRowType.CalculateRow && column.fieldId) { + return ( +
+ +
+ ); + } + + return
; + }, + [onResize] + ); + + return ( + + {({ height, width }: { height: number; width: number }) => ( + onScrollLeft(scrollLeft)} + rowCount={rows.length} + columnCount={columns.length} + columnWidth={(index) => columnWidth(index, width)} + rowHeight={rowHeight} + overscanRowCount={5} + overscanColumnCount={5} + style={{ + overscrollBehavior: 'none', + }} + itemKey={getItemKey} + itemData={{ columns, rows }} + outerElementType={AFScroller} + > + {Cell} + + )} + + ); +}; + +export default GridTable; diff --git a/frontend/appflowy_web_app/src/components/database/components/grid-table/index.ts b/frontend/appflowy_web_app/src/components/database/components/grid-table/index.ts new file mode 100644 index 0000000000..49518fa391 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/grid-table/index.ts @@ -0,0 +1 @@ +export * from './GridTable'; diff --git a/frontend/appflowy_web_app/src/components/database/components/sorts/Sort.tsx b/frontend/appflowy_web_app/src/components/database/components/sorts/Sort.tsx new file mode 100644 index 0000000000..37575224ac --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/sorts/Sort.tsx @@ -0,0 +1,20 @@ +import { useSortSelector } from '@/application/database-yjs'; +import SortCondition from '@/components/database/components/sorts/SortCondition'; +import React from 'react'; +import { FieldDisplay } from 'src/components/database/components/field'; + +function Sort({ sortId }: { sortId: string }) { + const sort = useSortSelector(sortId); + + if (!sort) return null; + return ( +
+
+ +
+ +
+ ); +} + +export default Sort; diff --git a/frontend/appflowy_web_app/src/components/database/components/sorts/SortCondition.tsx b/frontend/appflowy_web_app/src/components/database/components/sorts/SortCondition.tsx new file mode 100644 index 0000000000..78457da1ca --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/sorts/SortCondition.tsx @@ -0,0 +1,30 @@ +import { Sort } from '@/application/database-yjs'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as ArrowDownSvg } from '$icons/16x/arrow_down.svg'; + +function SortCondition({ sort }: { sort: Sort }) { + const condition = sort.condition; + const { t } = useTranslation(); + const conditionText = useMemo(() => { + switch (condition) { + case 0: + return t('grid.sort.ascending'); + case 1: + return t('grid.sort.descending'); + } + }, [condition, t]); + + return ( +
+ {conditionText} + +
+ ); +} + +export default SortCondition; diff --git a/frontend/appflowy_web_app/src/components/database/components/sorts/SortList.tsx b/frontend/appflowy_web_app/src/components/database/components/sorts/SortList.tsx new file mode 100644 index 0000000000..a657b4a0b9 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/sorts/SortList.tsx @@ -0,0 +1,17 @@ +import { useSortsSelector } from '@/application/database-yjs'; +import Sort from '@/components/database/components/sorts/Sort'; +import React from 'react'; + +function SortList() { + const sorts = useSortsSelector(); + + return ( +
+ {sorts.map((sortId) => ( + + ))} +
+ ); +} + +export default SortList; diff --git a/frontend/appflowy_web_app/src/components/database/components/sorts/Sorts.tsx b/frontend/appflowy_web_app/src/components/database/components/sorts/Sorts.tsx new file mode 100644 index 0000000000..a00aeea20c --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/sorts/Sorts.tsx @@ -0,0 +1,43 @@ +import { useSortsSelector } from '@/application/database-yjs'; +import { Popover } from '@/components/_shared/popover'; +import SortList from '@/components/database/components/sorts/SortList'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as SortSvg } from '$icons/16x/sort_ascending.svg'; +import { ReactComponent as ArrowDownSvg } from '$icons/16x/arrow_down.svg'; + +export function Sorts() { + const { t } = useTranslation(); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const sorts = useSortsSelector(); + + if (sorts.length === 0) return null; + return ( + <> +
{ + setAnchorEl(e.currentTarget); + }} + className='flex cursor-pointer items-center gap-1 rounded-full border border-line-divider px-2 py-1 text-xs hover:border-fill-default hover:text-fill-default hover:shadow-sm' + > + + {t('grid.settings.sort')} + +
+ {open && ( + { + setAnchorEl(null); + }} + > + + + )} + + ); +} + +export default Sorts; diff --git a/frontend/appflowy_web_app/src/components/database/components/sorts/index.ts b/frontend/appflowy_web_app/src/components/database/components/sorts/index.ts new file mode 100644 index 0000000000..467acd9081 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/sorts/index.ts @@ -0,0 +1 @@ +export * from './Sorts'; diff --git a/frontend/appflowy_web_app/src/components/database/components/tabs/DatabaseTabs.tsx b/frontend/appflowy_web_app/src/components/database/components/tabs/DatabaseTabs.tsx new file mode 100644 index 0000000000..65a1b238bb --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/tabs/DatabaseTabs.tsx @@ -0,0 +1,97 @@ +import { ViewLayout, YjsFolderKey, YView } from '@/application/collab.type'; +import { useFolderContext } from '@/application/folder-yjs'; +import { useId } from '@/components/_shared/context-provider/IdProvider'; +import { DatabaseActions } from '@/components/database/components/conditions'; +import { forwardRef, FunctionComponent, SVGProps, useCallback, useEffect, useMemo } from 'react'; +import { ViewTabs, ViewTab } from './ViewTabs'; +import { useTranslation } from 'react-i18next'; + +import { ReactComponent as GridSvg } from '$icons/16x/grid.svg'; +import { ReactComponent as BoardSvg } from '$icons/16x/board.svg'; +import { ReactComponent as CalendarSvg } from '$icons/16x/date.svg'; +import { ReactComponent as DocumentSvg } from '$icons/16x/document.svg'; + +export interface DatabaseTabBarProps { + viewIds: string[]; + selectedViewId?: string; + setSelectedViewId?: (viewId: string) => void; +} + +const DatabaseIcons: { + [key in ViewLayout]: FunctionComponent & { title?: string | undefined }>; +} = { + [ViewLayout.Document]: DocumentSvg, + [ViewLayout.Grid]: GridSvg, + [ViewLayout.Board]: BoardSvg, + [ViewLayout.Calendar]: CalendarSvg, +}; + +export const DatabaseTabs = forwardRef( + ({ viewIds, selectedViewId, setSelectedViewId }, ref) => { + const objectId = useId().objectId; + const { t } = useTranslation(); + const folder = useFolderContext(); + const handleChange = (_: React.SyntheticEvent, newValue: string) => { + setSelectedViewId?.(newValue); + }; + + useEffect(() => { + if (selectedViewId === undefined) { + setSelectedViewId?.(objectId); + } + }, [selectedViewId, setSelectedViewId, objectId]); + const isSelected = useMemo(() => viewIds.some((viewId) => viewId === selectedViewId), [viewIds, selectedViewId]); + + const getFolderView = useCallback( + (viewId: string) => { + if (!folder) return null; + return folder.get(YjsFolderKey.views)?.get(viewId) as YView | null; + }, + [folder] + ); + + if (viewIds.length === 0) return null; + return ( +
+
+ + {viewIds.map((viewId, index) => { + const view = getFolderView(viewId); + + if (!view) return null; + const layout = Number(view.get(YjsFolderKey.layout)) as ViewLayout; + const Icon = DatabaseIcons[layout]; + const name = view.get(YjsFolderKey.name); + + return ( + } + iconPosition='start' + color='inherit' + label={name || t('grid.title.placeholder')} + value={viewId} + /> + ); + })} + +
+ +
+ ); + } +); diff --git a/frontend/appflowy_web_app/src/components/database/components/tabs/TextButton.tsx b/frontend/appflowy_web_app/src/components/database/components/tabs/TextButton.tsx new file mode 100644 index 0000000000..7bbf91cf65 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/tabs/TextButton.tsx @@ -0,0 +1,18 @@ +import { Button, ButtonProps, styled } from '@mui/material'; + +export const TextButton = styled((props: ButtonProps) => ( +
diff --git a/frontend/appflowy_web_app/src/components/layout/layout.scss b/frontend/appflowy_web_app/src/components/layout/layout.scss index 4133489130..bac3baae69 100644 --- a/frontend/appflowy_web_app/src/components/layout/layout.scss +++ b/frontend/appflowy_web_app/src/components/layout/layout.scss @@ -35,6 +35,7 @@ .appflowy-scroll-container { &::-webkit-scrollbar { width: 0; + height: 0; } } @@ -44,20 +45,14 @@ opacity: 60%; } -.workspaces { - ::-webkit-scrollbar { - width: 0px; - } -} - - -.MuiPopover-root, .MuiPaper-root { +.workspaces, .database-conditions, .grid-scroll-table { ::-webkit-scrollbar { width: 0; height: 0; } } + .view-icon { @apply flex w-fit cursor-pointer rounded-lg py-2 text-6xl; font-family: "Apple Color Emoji", "Segoe UI Emoji", NotoColorEmoji, "Noto Color Emoji", "Segoe UI Symbol", "Android Emoji", EmojiSymbols; diff --git a/frontend/appflowy_web_app/src/pages/DatabasePage.tsx b/frontend/appflowy_web_app/src/pages/DatabasePage.tsx new file mode 100644 index 0000000000..e1fbfb8067 --- /dev/null +++ b/frontend/appflowy_web_app/src/pages/DatabasePage.tsx @@ -0,0 +1,10 @@ +import { Database } from '@/components/database'; +import React from 'react'; + +function DatabasePage () { + return ( + + ); +} + +export default DatabasePage; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/pages/ProductPage.tsx b/frontend/appflowy_web_app/src/pages/ProductPage.tsx index 8080e339ef..f7b5615a77 100644 --- a/frontend/appflowy_web_app/src/pages/ProductPage.tsx +++ b/frontend/appflowy_web_app/src/pages/ProductPage.tsx @@ -1,34 +1,44 @@ import { CollabType } from '@/application/collab.type'; import { IdProvider } from '@/components/_shared/context-provider/IdProvider'; +import DatabasePage from '@/pages/DatabasePage'; import React, { useMemo } from 'react'; import { useParams } from 'react-router-dom'; import DocumentPage from '@/pages/DocumentPage'; enum URL_COLLAB_TYPE { DOCUMENT = 'document', - DATABASE = 'database', + GRID = 'grid', + BOARD = 'board', + CALENDAR = 'calendar', } const collabTypeMap: Record = { [URL_COLLAB_TYPE.DOCUMENT]: CollabType.Document, - [URL_COLLAB_TYPE.DATABASE]: CollabType.Database, + [URL_COLLAB_TYPE.GRID]: CollabType.WorkspaceDatabase, + [URL_COLLAB_TYPE.BOARD]: CollabType.WorkspaceDatabase, + [URL_COLLAB_TYPE.CALENDAR]: CollabType.WorkspaceDatabase, }; function ProductPage() { - const { workspaceId, collabType, objectId } = useParams(); + const { workspaceId, type, objectId } = useParams(); const PageComponent = useMemo(() => { - switch (collabType) { + switch (type) { case URL_COLLAB_TYPE.DOCUMENT: return DocumentPage; + case URL_COLLAB_TYPE.GRID: + case URL_COLLAB_TYPE.BOARD: + case URL_COLLAB_TYPE.CALENDAR: + return DatabasePage; default: return null; } - }, [collabType]); + }, [type]); - if (!workspaceId || !collabType || !objectId) return null; + console.log(workspaceId, type, objectId); + if (!workspaceId || !type || !objectId) return null; return ( - + {PageComponent && } ); diff --git a/frontend/appflowy_web_app/src/styles/variables/dark.variables.css b/frontend/appflowy_web_app/src/styles/variables/dark.variables.css index b82d97e5be..6753969ca0 100644 --- a/frontend/appflowy_web_app/src/styles/variables/dark.variables.css +++ b/frontend/appflowy_web_app/src/styles/variables/dark.variables.css @@ -1,12 +1,12 @@ /** * Do not edit directly -* Generated on Mon, 25 Mar 2024 05:19:13 GMT +* Generated on Thu, 09 May 2024 03:26:45 GMT * Generated from $pnpm css:variables */ :root[data-dark-mode=true] { --base-light-neutral-50: #f9fafd; - --base-light-neutral-100: #edeef2; + --base-light-neutral-100: #e5e5e5; --base-light-neutral-200: #e2e4eb; --base-light-neutral-300: #f2f2f2; --base-light-neutral-400: #e0e0e0; diff --git a/frontend/appflowy_web_app/src/styles/variables/light.variables.css b/frontend/appflowy_web_app/src/styles/variables/light.variables.css index 0477655f66..b1494114bd 100644 --- a/frontend/appflowy_web_app/src/styles/variables/light.variables.css +++ b/frontend/appflowy_web_app/src/styles/variables/light.variables.css @@ -1,12 +1,12 @@ /** * Do not edit directly -* Generated on Mon, 25 Mar 2024 05:19:13 GMT +* Generated on Thu, 09 May 2024 03:26:45 GMT * Generated from $pnpm css:variables */ :root { --base-light-neutral-50: #f9fafd; - --base-light-neutral-100: #edeef2; + --base-light-neutral-100: #e5e5e5; --base-light-neutral-200: #e2e4eb; --base-light-neutral-300: #f2f2f2; --base-light-neutral-400: #e0e0e0; @@ -83,7 +83,7 @@ --icon-disabled: #e0e0e0; --icon-on-toolbar: #ffffff; --line-border: #bdbdbd; - --line-divider: #edeef2; + --line-divider: #e5e5e5; --line-on-toolbar: #4f4f4f; --fill-toolbar: #333333; --fill-default: #00bcf0; @@ -91,7 +91,7 @@ --fill-pressed: #009fd1; --fill-active: #e0f8ff; --fill-list-hover: #e0f8ff; - --fill-list-active: #edeef2; + --fill-list-active: #f9fafd; --content-blue-400: #00bcf0; --content-blue-300: #52d1f4; --content-blue-600: #009fd1; @@ -120,5 +120,5 @@ --tint-yellow: #fff2cd; --shadow: 0px 0px 10px 0px rgba(0,0,0,0.1); --scrollbar-thumb: #bdbdbd; - --scrollbar-track: #edeef2; + --scrollbar-track: #e5e5e5; } \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/utils/time.ts b/frontend/appflowy_web_app/src/utils/time.ts index 3b6920fb34..792b72ee61 100644 --- a/frontend/appflowy_web_app/src/utils/time.ts +++ b/frontend/appflowy_web_app/src/utils/time.ts @@ -1,10 +1,6 @@ import dayjs from 'dayjs'; -export enum DateFormat { - Date = 'MMM D, YYYY', - DateTime = 'MMM D, YYYY h:mm A', -} - -export function renderDate(date: string, format: DateFormat = DateFormat.Date): string { +export function renderDate(date: string, format: string, isUnix?: boolean): string { + if (isUnix) return dayjs.unix(Number(date)).format(format); return dayjs(date).format(format); } diff --git a/frontend/appflowy_web_app/src/utils/url.ts b/frontend/appflowy_web_app/src/utils/url.ts index 8d67f3583f..a10cf9ca85 100644 --- a/frontend/appflowy_web_app/src/utils/url.ts +++ b/frontend/appflowy_web_app/src/utils/url.ts @@ -1,12 +1,14 @@ import { getPlatform } from '@/utils/platform'; -import validator from 'validator'; +import isURL from 'validator/lib/isURL'; +import isIP from 'validator/lib/isIP'; +import isFQDN from 'validator/lib/isFQDN'; export const downloadPage = 'https://appflowy.io/download'; export const openAppFlowySchema = 'appflowy-flutter://'; export function isValidUrl(input: string) { - return validator.isURL(input, { require_protocol: true, require_host: false }); + return isURL(input, { require_protocol: true, require_host: false }); } // Process the URL to make sure it's a valid URL @@ -20,7 +22,7 @@ export function processUrl(input: string) { const domain = input.split('/')[0]; - if (validator.isIP(domain) || validator.isFQDN(domain)) { + if (isIP(domain) || isFQDN(domain)) { processedUrl = `https://${input}`; if (isValidUrl(processedUrl)) { return processedUrl; diff --git a/frontend/appflowy_web_app/style-dictionary/tailwind/box-shadow.cjs b/frontend/appflowy_web_app/style-dictionary/tailwind/box-shadow.cjs index 00647333e2..9de67fc1be 100644 --- a/frontend/appflowy_web_app/style-dictionary/tailwind/box-shadow.cjs +++ b/frontend/appflowy_web_app/style-dictionary/tailwind/box-shadow.cjs @@ -1,6 +1,6 @@ /** * Do not edit directly -* Generated on Mon, 25 Mar 2024 05:19:13 GMT +* Generated on Thu, 09 May 2024 03:26:45 GMT * Generated from $pnpm css:variables */ diff --git a/frontend/appflowy_web_app/style-dictionary/tailwind/colors.cjs b/frontend/appflowy_web_app/style-dictionary/tailwind/colors.cjs index 798741f06c..63e679a90a 100644 --- a/frontend/appflowy_web_app/style-dictionary/tailwind/colors.cjs +++ b/frontend/appflowy_web_app/style-dictionary/tailwind/colors.cjs @@ -1,6 +1,6 @@ /** * Do not edit directly -* Generated on Mon, 25 Mar 2024 05:19:13 GMT +* Generated on Thu, 09 May 2024 03:26:45 GMT * Generated from $pnpm css:variables */ diff --git a/frontend/appflowy_web_app/style-dictionary/tokens/base.json b/frontend/appflowy_web_app/style-dictionary/tokens/base.json index 4e31b0523d..f92d39267f 100644 --- a/frontend/appflowy_web_app/style-dictionary/tokens/base.json +++ b/frontend/appflowy_web_app/style-dictionary/tokens/base.json @@ -7,7 +7,7 @@ "type": "color" }, "100": { - "value": "#edeef2", + "value": "#e5e5e5", "type": "color" }, "200": { diff --git a/frontend/appflowy_web_app/tsconfig.json b/frontend/appflowy_web_app/tsconfig.json index de30c24901..05dcd8d587 100644 --- a/frontend/appflowy_web_app/tsconfig.json +++ b/frontend/appflowy_web_app/tsconfig.json @@ -27,7 +27,7 @@ "node", "jest" ], - "baseUrl": "./", + "baseUrl": ".", "paths": { "@/*": [ "src/*" @@ -37,6 +37,9 @@ ], "$client-services": [ "src/application/services/js-services" + ], + "$icons/*": [ + "../resources/flowy-flowy_icons/*" ] } }, diff --git a/frontend/appflowy_web_app/vite.config.ts b/frontend/appflowy_web_app/vite.config.ts index b2621799ed..0e4cfebb4b 100644 --- a/frontend/appflowy_web_app/vite.config.ts +++ b/frontend/appflowy_web_app/vite.config.ts @@ -5,7 +5,9 @@ import wasm from 'vite-plugin-wasm'; import { visualizer } from 'rollup-plugin-visualizer'; import usePluginImport from 'vite-plugin-importer'; import { totalBundleSize } from 'vite-plugin-total-bundle-size'; +import path from 'path'; +const resourcesPath = path.resolve(__dirname, '../resources'); const isDev = process.env.NODE_ENV === 'development'; // https://vitejs.dev/config/ export default defineConfig({ @@ -104,8 +106,8 @@ export default defineConfig({ id.includes('/react-is@') || id.includes('/yjs@') || id.includes('/y-indexeddb@') || - id.includes('/dexie@') || - id.includes('/redux') + id.includes('/redux') || + id.includes('/react-custom-scrollbars') ) { return 'common'; } @@ -124,6 +126,7 @@ export default defineConfig({ ? `${__dirname}/src/application/services/tauri-services` : `${__dirname}/src/application/services/js-services`, }, + { find: '$icons', replacement: `${resourcesPath}/flowy_icons/` }, ], },