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 (
+
+ );
+}
+
+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) => (
+
+ ))}
+ }
+ size='small'
+ >
+ {t('grid.settings.addFilter')}
+
+ >
+ );
+}
+
+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 (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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) => (
+
+))(() => ({
+ padding: '4px 6px',
+ fontSize: '0.75rem',
+ lineHeight: '1rem',
+ fontWeight: 400,
+ minWidth: 'unset',
+}));
diff --git a/frontend/appflowy_web_app/src/components/database/components/tabs/ViewTabs.tsx b/frontend/appflowy_web_app/src/components/database/components/tabs/ViewTabs.tsx
new file mode 100644
index 0000000000..a9c58e42c7
--- /dev/null
+++ b/frontend/appflowy_web_app/src/components/database/components/tabs/ViewTabs.tsx
@@ -0,0 +1,52 @@
+import { styled, Tab, TabProps, Tabs, TabsProps } from '@mui/material';
+import { HTMLAttributes } from 'react';
+
+export const ViewTabs = styled((props: TabsProps) => )({
+ minHeight: '28px',
+
+ '& .MuiTabs-scroller': {
+ paddingBottom: '2px',
+ },
+});
+
+export const ViewTab = styled((props: TabProps) => )({
+ padding: '0 12px',
+ minHeight: '24px',
+ fontSize: '12px',
+ minWidth: 'unset',
+ margin: '4px 0',
+ borderRadius: 0,
+ '&:hover': {
+ backgroundColor: 'transparent !important',
+ color: 'inherit',
+ },
+ '&.Mui-selected': {
+ color: 'inherit',
+ backgroundColor: 'transparent',
+ },
+});
+
+interface TabPanelProps extends HTMLAttributes {
+ children?: React.ReactNode;
+ index: number;
+ value: number;
+}
+
+export function TabPanel(props: TabPanelProps) {
+ const { children, value, index, ...other } = props;
+
+ const isActivated = value === index;
+
+ return (
+
+ {isActivated ? children : null}
+
+ );
+}
diff --git a/frontend/appflowy_web_app/src/components/database/components/tabs/index.ts b/frontend/appflowy_web_app/src/components/database/components/tabs/index.ts
new file mode 100644
index 0000000000..8d2722a633
--- /dev/null
+++ b/frontend/appflowy_web_app/src/components/database/components/tabs/index.ts
@@ -0,0 +1,2 @@
+export * from './DatabaseTabs';
+export * from './ViewTabs';
diff --git a/frontend/appflowy_web_app/src/components/database/grid/Grid.tsx b/frontend/appflowy_web_app/src/components/database/grid/Grid.tsx
new file mode 100644
index 0000000000..eacb2b725a
--- /dev/null
+++ b/frontend/appflowy_web_app/src/components/database/grid/Grid.tsx
@@ -0,0 +1,45 @@
+import { GridRowsContext, useDatabase, useGridRowOrders, useViewId } from '@/application/database-yjs';
+import { useRenderColumns } from '@/components/database/components/grid-column';
+import { CircularProgress } from '@mui/material';
+import React, { useState } from 'react';
+import { GridHeader } from 'src/components/database/components/grid-header';
+import { GridTable } from 'src/components/database/components/grid-table';
+
+export function Grid() {
+ const database = useDatabase();
+ const [scrollLeft, setScrollLeft] = useState(0);
+ const viewId = useViewId() || '';
+ const { columns, columnWidth } = useRenderColumns(viewId);
+ const rowOrders = useGridRowOrders();
+
+ if (!database || !rowOrders) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+export default Grid;
diff --git a/frontend/appflowy_web_app/src/components/database/grid/index.ts b/frontend/appflowy_web_app/src/components/database/grid/index.ts
new file mode 100644
index 0000000000..762542e7cb
--- /dev/null
+++ b/frontend/appflowy_web_app/src/components/database/grid/index.ts
@@ -0,0 +1 @@
+export * from './Grid';
diff --git a/frontend/appflowy_web_app/src/components/database/index.ts b/frontend/appflowy_web_app/src/components/database/index.ts
new file mode 100644
index 0000000000..8ef9c34dc1
--- /dev/null
+++ b/frontend/appflowy_web_app/src/components/database/index.ts
@@ -0,0 +1,3 @@
+import { lazy } from 'react';
+
+export const Database = lazy(() => import('./Database'));
diff --git a/frontend/appflowy_web_app/src/components/editor/CollaborativeEditor.tsx b/frontend/appflowy_web_app/src/components/editor/CollaborativeEditor.tsx
index 83e36b88f8..f55dde01a4 100644
--- a/frontend/appflowy_web_app/src/components/editor/CollaborativeEditor.tsx
+++ b/frontend/appflowy_web_app/src/components/editor/CollaborativeEditor.tsx
@@ -1,9 +1,10 @@
-import { YjsFolderKey } from '@/application/collab.type';
+import { CollabOrigin, YjsFolderKey } from '@/application/collab.type';
import { useViewSelector } from '@/application/folder-yjs';
import { withYjs, YjsEditor } from '@/application/slate-yjs/plugins/withYjs';
import { useId } from '@/components/_shared/context-provider/IdProvider';
import { CustomEditor } from '@/components/editor/command';
import EditorEditable from '@/components/editor/Editable';
+import { useEditorContext } from '@/components/editor/EditorContext';
import { withPlugins } from '@/components/editor/plugins';
import React, { useEffect, useMemo, useState } from 'react';
import { createEditor, Descendant } from 'slate';
@@ -13,7 +14,13 @@ import * as Y from 'yjs';
const defaultInitialValue: Descendant[] = [];
function CollaborativeEditor({ doc }: { doc: Y.Doc }) {
- const editor = useMemo(() => doc && (withPlugins(withReact(withYjs(createEditor(), doc))) as YjsEditor), [doc]);
+ const context = useEditorContext();
+ // if readOnly, collabOrigin is Local, otherwise RemoteSync
+ const collabOrigin = context.readOnly ? CollabOrigin.Local : CollabOrigin.LocalSync;
+ const editor = useMemo(
+ () => doc && (withPlugins(withReact(withYjs(createEditor(), doc, collabOrigin))) as YjsEditor),
+ [doc, collabOrigin]
+ );
const [connected, setIsConnected] = useState(false);
const viewId = useId()?.objectId || '';
const { view } = useViewSelector(viewId);
diff --git a/frontend/appflowy_web_app/src/components/editor/command/index.ts b/frontend/appflowy_web_app/src/components/editor/command/index.ts
index dc4668760c..d4ee1a6e28 100644
--- a/frontend/appflowy_web_app/src/components/editor/command/index.ts
+++ b/frontend/appflowy_web_app/src/components/editor/command/index.ts
@@ -24,7 +24,10 @@ export const CustomEditor = {
}
if (node.type === InlineBlockType.Mention && (node.data as Mention)?.type === MentionType.Date) {
- return renderDate((node.data as Mention).date || '');
+ const date = (node.data as Mention).date || '';
+ const isUnix = date?.length === 10;
+
+ return renderDate(date, 'MMM DD, YYYY', isUnix);
}
}
diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageEmpty.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageEmpty.tsx
index ab95cbe37d..32ea2881f9 100644
--- a/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageEmpty.tsx
+++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageEmpty.tsx
@@ -1,6 +1,6 @@
import { ImageBlockNode } from '@/components/editor/editor.type';
import React from 'react';
-import { ReactComponent as ImageIcon } from '@/assets/image.svg';
+import { ReactComponent as ImageIcon } from '$icons/16x/image.svg';
import { useTranslation } from 'react-i18next';
function ImageEmpty(_: { containerRef: React.RefObject; onEscape: () => void; node: ImageBlockNode }) {
diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/todo_list/CheckboxIcon.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/todo_list/CheckboxIcon.tsx
index aad5969e86..007903b89a 100644
--- a/frontend/appflowy_web_app/src/components/editor/components/blocks/todo_list/CheckboxIcon.tsx
+++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/todo_list/CheckboxIcon.tsx
@@ -1,7 +1,7 @@
import { TodoListNode } from '@/components/editor/editor.type';
import React from 'react';
-import { ReactComponent as CheckboxCheckSvg } from '@/assets/database/checkbox-check.svg';
-import { ReactComponent as CheckboxUncheckSvg } from '@/assets/database/checkbox-uncheck.svg';
+import { ReactComponent as CheckboxCheckSvg } from '$icons/16x/check_filled.svg';
+import { ReactComponent as CheckboxUncheckSvg } from '$icons/16x/uncheck.svg';
function CheckboxIcon({ block, className }: { block: TodoListNode; className: string }) {
const { checked } = block.data;
diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle_list/ToggleIcon.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle_list/ToggleIcon.tsx
index 4e2735d128..e7a2d288f2 100644
--- a/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle_list/ToggleIcon.tsx
+++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle_list/ToggleIcon.tsx
@@ -1,6 +1,6 @@
import { ToggleListNode } from '@/components/editor/editor.type';
import React from 'react';
-import { ReactComponent as RightSvg } from '@/assets/more.svg';
+import { ReactComponent as RightSvg } from '$icons/16x/more.svg';
function ToggleIcon({ block, className }: { block: ToggleListNode; className: string }) {
const { collapsed } = block.data;
diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionDate.tsx b/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionDate.tsx
index fe1e844a4a..c430968b52 100644
--- a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionDate.tsx
+++ b/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionDate.tsx
@@ -1,11 +1,11 @@
import { renderDate } from '@/utils/time';
import React, { useMemo } from 'react';
-import { ReactComponent as DateSvg } from '@/assets/date.svg';
-import { ReactComponent as ReminderSvg } from '@/assets/clock_alarm.svg';
+import { ReactComponent as DateSvg } from '$icons/16x/date.svg';
+import { ReactComponent as ReminderSvg } from '$icons/16x/clock_alarm.svg';
function MentionDate({ date, reminder }: { date: string; reminder?: { id: string; option: string } }) {
const dateFormat = useMemo(() => {
- return renderDate(date);
+ return renderDate(date, 'MMM D, YYYY');
}, [date]);
return (
diff --git a/frontend/appflowy_web_app/src/components/error/ErrorModal.tsx b/frontend/appflowy_web_app/src/components/error/ErrorModal.tsx
index c4382c8182..dc8d664a01 100644
--- a/frontend/appflowy_web_app/src/components/error/ErrorModal.tsx
+++ b/frontend/appflowy_web_app/src/components/error/ErrorModal.tsx
@@ -1,6 +1,6 @@
import { ReactComponent as InformationSvg } from '@/assets/information.svg';
-import { ReactComponent as CloseSvg } from '@/assets/close.svg';
-import { Button } from "@mui/material";
+import { ReactComponent as CloseSvg } from '$icons/16x/close.svg';
+import { Button } from '@mui/material';
export const ErrorModal = ({ message, onClose }: { message: string; onClose: () => void }) => {
return (
@@ -22,9 +22,11 @@ export const ErrorModal = ({ message, onClose }: { message: string; onClose: ()
Oops.. something went wrong
{message}
-