feat: support document apply remote events (#5436)

* feat: support document apply remote events

* fix: add tests for database

* fix: add test for filter,sort and group

* fix: jest ci

* fix: jest ci

* fix: jest ci

* fix: jest ci

* fix: cypress test
This commit is contained in:
Kilu.He 2024-06-03 11:20:45 +08:00 committed by GitHub
parent 0d64aa4311
commit 4d42c9ea68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
119 changed files with 5626 additions and 896 deletions

View File

@ -52,11 +52,11 @@ jobs:
working-directory: frontend/appflowy_web_app working-directory: frontend/appflowy_web_app
run: | run: |
pnpm install pnpm install
- name: test and lint - name: Run lint check
working-directory: frontend/appflowy_web_app working-directory: frontend/appflowy_web_app
run: | run: |
pnpm run lint pnpm run lint
pnpm run test:unit
- name: build and analyze - name: build and analyze
working-directory: frontend/appflowy_web_app working-directory: frontend/appflowy_web_app
run: | run: |

View File

@ -1,4 +1,4 @@
name: Cypress Tests name: Web Code Coverage
on: on:
pull_request: pull_request:
@ -13,7 +13,7 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
cypress-run: test:
if: github.event.pull_request.draft != true if: github.event.pull_request.draft != true
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
@ -45,4 +45,15 @@ jobs:
component: true component: true
build: pnpm run build build: pnpm run build
start: pnpm run start start: pnpm run start
browser: chrome browser: chrome
- name: Jest run
working-directory: frontend/appflowy_web_app
run: |
pnpm run test:unit
- name: Generate and post coverage summary
working-directory: frontend/appflowy_web_app
run: |
pnpm run merge-coverage

View File

@ -6,4 +6,5 @@ tsconfig.json
**/backend/** **/backend/**
vite.config.ts vite.config.ts
**/*.cy.tsx **/*.cy.tsx
*.config.ts *.config.ts
coverage/

View File

@ -4,4 +4,5 @@ src-tauri/
.eslintrc.cjs .eslintrc.cjs
tsconfig.json tsconfig.json
src/application/services/tauri-services/ src/application/services/tauri-services/
vite.config.ts vite.config.ts
coverage/

View File

@ -29,4 +29,7 @@ src/@types/translations/*.json
src/application/services/tauri-services/backend/models/ src/application/services/tauri-services/backend/models/
src/application/services/tauri-services/backend/events/ src/application/services/tauri-services/backend/events/
.env .env
coverage
.nyc_output

View File

@ -0,0 +1,22 @@
{
"all": true,
"extends": "@istanbuljs/nyc-config-typescript",
"include": [
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": [
"cypress/**/*.*",
"**/*.d.ts",
"**/*.cy.tsx",
"**/*.cy.ts"
],
"reporter": [
"text",
"html",
"text-summary",
"json"
],
"temp-dir": "coverage/.nyc_output",
"report-dir": "coverage/cypress"
}

View File

@ -1,11 +1,22 @@
import { defineConfig } from 'cypress'; import { defineConfig } from 'cypress';
import registerCodeCoverageTasks from '@cypress/code-coverage/task';
export default defineConfig({ export default defineConfig({
env: {
codeCoverage: {
exclude: ['cypress/**/*.*', '**/__tests__/**/*.*', '**/*.test.*'],
},
},
component: { component: {
devServer: { devServer: {
framework: 'react', framework: 'react',
bundler: 'vite', bundler: 'vite',
}, },
setupNodeEvents(on, config) {
registerCodeCoverageTasks(on, config);
return config;
},
supportFile: 'cypress/support/component.ts',
}, },
retries: { retries: {
// Configure retry attempts for `cypress run` // Configure retry attempts for `cypress run`

View File

@ -0,0 +1,11 @@
{
"id": "9eebea03-3ed5-4298-86b2-a7f77856d48b",
"name": "workspace",
"icon": "",
"owner": {
"id": 0,
"name": "system"
},
"type": 0,
"workspaceDatabaseId": "375874be-7a4f-4b7c-8b89-1dc9a39838f4"
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
[{"database_id":"037a985f-f369-4c4a-8011-620012850a68","created_at":"1713429700","views":["48c52cf7-bf98-43fa-96ad-b31aade9b071"]},{"database_id":"daea6aee-9365-4703-a8e2-a2fa6a07b214","created_at":"1714449533","views":["b6347acb-3174-4f0e-98e9-dcce07e5dbf7"]},{"database_id":"4c658817-20db-4f56-b7f9-0637a22dfeb6","created_at":"0","views":["7d2148fc-cace-4452-9c5c-96e52e6bf8b5","e410747b-5f2f-45a0-b2f7-890ad3001355","2143e95d-5dcb-4e0f-bb2c-50944e6e019f","a5566e49-f156-4168-9b2d-17926c5da329","135615fa-66f7-4451-9b54-d7e99445fca4","b4e77203-5c8b-48df-bbc5-2e1143eb0e61","a6af311f-cbc8-42c2-b801-7115619c3776"]},{"database_id":"4c658817-20db-4f56-b7f9-0637a22dfeb6","created_at":"0","views":["7d2148fc-cace-4452-9c5c-96e52e6bf8b5","e97877f5-c365-4025-9e6a-e590c4b19dbb","f0c59921-04ee-4971-995c-79b7fd8c00e2","7eb697cd-6a55-40bb-96ac-0d4a3bc924b2"]},{"database_id":"ee63da2b-aa2a-4d0b-aab0-59008635363a","created_at":"0","views":["2c1ee95a-1b09-4a1f-8d5e-501bc4861a9d","91ea7c08-f6b3-4b81-aa1e-d3664686186f"]},{"database_id":"e788f014-d0d3-4dfe-81ef-aa1ebb4d6366","created_at":"0","views":["1b0e322d-4909-4c63-914a-d034fc363097","350f425b-b671-4e2d-8182-5998a6e62924"]},{"database_id":"ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d","created_at":"0","views":["0ce13415-6cce-4497-94c6-475ad96c249e","e4c89421-12b2-4d02-863d-20949eec9271"]},{"database_id":"ce267d12-3b61-4ebb-bb03-d65272f5f817","created_at":"0","views":["ee3ae8ce-959a-4df3-8734-40b535ff88e3","66a6f3bc-c78f-4f74-a09e-08d4717bf1fd","2bf50c03-f41f-4363-b5b1-101216a6c5cc"]}]

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"208d248f-5c08-4be5-a022-e0a97c2d705e":[16,1,162,212,253,234,14,0,161,166,231,212,218,8,3,39,1,245,198,128,205,14,0,161,233,140,128,164,8,5,2,1,165,222,139,132,12,0,161,128,181,233,166,8,1,7,1,179,227,145,238,11,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,10,2,213,228,161,169,9,0,161,233,140,128,164,8,5,1,161,245,198,128,205,14,1,9,2,185,222,141,169,9,0,161,140,225,231,182,6,2,4,168,185,222,141,169,9,3,1,122,0,0,0,0,102,88,52,85,1,138,182,251,229,8,0,161,162,212,253,234,14,38,7,1,166,231,212,218,8,0,161,165,222,139,132,12,6,4,1,128,181,233,166,8,0,161,179,227,145,238,11,9,2,1,233,140,128,164,8,0,161,221,230,177,144,4,1,6,1,239,245,240,149,8,0,161,157,238,145,201,3,1,2,1,140,225,231,182,6,0,161,239,245,240,149,8,1,3,1,246,148,237,174,6,0,161,138,182,251,229,8,6,5,16,221,174,135,220,5,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,221,174,135,220,5,0,2,105,100,1,119,36,50,48,56,100,50,52,56,102,45,53,99,48,56,45,52,98,101,53,45,97,48,50,50,45,101,48,97,57,55,99,50,100,55,48,53,101,40,0,221,174,135,220,5,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,97,100,55,100,99,52,53,98,45,52,52,98,53,45,52,57,56,102,45,98,102,97,50,45,48,102,52,51,98,102,48,53,99,99,48,100,40,0,221,174,135,220,5,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,221,174,135,220,5,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,221,174,135,220,5,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,76,39,162,40,0,221,174,135,220,5,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,76,39,162,39,0,221,174,135,220,5,0,5,99,101,108,108,115,1,39,0,221,174,135,220,5,9,6,121,52,52,50,48,119,1,40,0,221,174,135,220,5,10,4,100,97,116,97,1,119,4,117,76,117,51,40,0,221,174,135,220,5,10,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,3,39,0,221,174,135,220,5,9,6,51,111,45,90,115,109,1,40,0,221,174,135,220,5,13,4,100,97,116,97,1,119,6,67,97,114,100,32,49,40,0,221,174,135,220,5,13,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,0,1,221,230,177,144,4,0,161,246,148,237,174,6,4,2,1,157,238,145,201,3,0,161,213,228,161,169,9,9,2,15,128,181,233,166,8,1,0,2,162,212,253,234,14,1,0,39,165,222,139,132,12,1,0,7,166,231,212,218,8,1,0,4,233,140,128,164,8,1,0,6,138,182,251,229,8,1,0,7,140,225,231,182,6,1,0,3,239,245,240,149,8,1,0,2,179,227,145,238,11,1,0,10,245,198,128,205,14,1,0,2,246,148,237,174,6,1,0,5,213,228,161,169,9,1,0,10,185,222,141,169,9,1,0,4,221,230,177,144,4,1,0,2,157,238,145,201,3,1,0,2]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,61 +1 @@
{ {"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."}
"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."
}

View File

@ -25,9 +25,14 @@
// -- This will overwrite an existing command -- // -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
// //
import { YDoc } from '@/application/collab.type';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
import { JSDatabaseService } from '@/application/services/js-services/database.service';
import { JSDocumentService } from '@/application/services/js-services/document.service';
import { applyYDoc } from '@/application/ydoc/apply';
import * as Y from 'yjs';
Cypress.Commands.add('mockAPI', () => { Cypress.Commands.add('mockAPI', () => {
cy.fixture('sign_in_success').then((json) => { cy.fixture('sign_in_success').then((json) => {
cy.intercept('GET', `/api/user/verify/${json.access_token}`, { cy.intercept('GET', `/api/user/verify/${json.access_token}`, {
@ -45,3 +50,71 @@ Cypress.Commands.add('mockAPI', () => {
// cy.mockAPI(); // cy.mockAPI();
// }); // });
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Cypress.Commands.add('mockCurrentWorkspace', () => {
cy.fixture('current_workspace').then((workspace) => {
cy.stub(JSDatabaseService.prototype, 'currentWorkspace').resolves(workspace);
});
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Cypress.Commands.add('mockGetWorkspaceDatabases', () => {
cy.fixture('database/databases').then((databases) => {
cy.stub(JSDatabaseService.prototype, 'getWorkspaceDatabases').resolves(databases);
});
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Cypress.Commands.add('mockDatabase', () => {
cy.mockCurrentWorkspace();
cy.mockGetWorkspaceDatabases();
const ids = [
'4c658817-20db-4f56-b7f9-0637a22dfeb6',
'ce267d12-3b61-4ebb-bb03-d65272f5f817',
'ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d',
];
const mockOpenDatabase = cy.stub(JSDatabaseService.prototype, 'openDatabase');
ids.forEach((id) => {
cy.fixture(`database/${id}`).then((database) => {
cy.fixture(`database/rows/${id}`).then((rows) => {
const doc = new Y.Doc();
const rootRowsDoc = new Y.Doc();
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
const databaseState = new Uint8Array(database.data.doc_state);
applyYDoc(doc, databaseState);
Object.keys(rows).forEach((key) => {
const data = rows[key];
const rowDoc = new Y.Doc();
applyYDoc(rowDoc, new Uint8Array(data));
rowsFolder.set(key, rowDoc);
});
mockOpenDatabase.withArgs(id).resolves({
databaseDoc: doc,
rows: rowsFolder,
});
});
});
});
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Cypress.Commands.add('mockDocument', (id: string) => {
cy.fixture(`document/${id}`).then((subDocument) => {
const doc = new Y.Doc();
const state = new Uint8Array(subDocument.data.doc_state);
applyYDoc(doc, state);
cy.stub(JSDocumentService.prototype, 'openDocument').withArgs(id).resolves(doc);
});
});

View File

@ -14,6 +14,7 @@
// *********************************************************** // ***********************************************************
// Import commands.js using ES2015 syntax: // Import commands.js using ES2015 syntax:
import '@cypress/code-coverage/support';
import './commands'; import './commands';
import './document'; import './document';
// Alternatively you can use CommonJS syntax: // Alternatively you can use CommonJS syntax:
@ -31,6 +32,10 @@ declare global {
interface Chainable { interface Chainable {
mount: typeof mount; mount: typeof mount;
mockAPI: () => void; mockAPI: () => void;
mockDatabase: () => void;
mockCurrentWorkspace: () => void;
mockGetWorkspaceDatabases: () => void;
mockDocument: (id: string) => void;
} }
} }
} }
@ -39,3 +44,4 @@ Cypress.Commands.add('mount', mount);
// Example use: // Example use:
// cy.mount(<MyComponent />) // cy.mount(<MyComponent />)

View File

@ -17,4 +17,7 @@ module.exports = {
'(.*)/node_modules/nanoid/.+\\.(j|t)sx?$': 'ts-jest', '(.*)/node_modules/nanoid/.+\\.(j|t)sx?$': 'ts-jest',
}, },
'transformIgnorePatterns': [`/node_modules/(?!${esModules})`], 'transformIgnorePatterns': [`/node_modules/(?!${esModules})`],
testMatch: ['**/*.test.ts'],
coverageDirectory: '<rootDir>/coverage/jest',
collectCoverage: true,
}; };

View File

@ -19,7 +19,10 @@
"cypress:open": "cypress open", "cypress:open": "cypress open",
"test": "pnpm run test:unit && pnpm run test:components", "test": "pnpm run test:unit && pnpm run test:components",
"test:components": "cypress run --component --browser chrome --headless", "test:components": "cypress run --component --browser chrome --headless",
"test:unit": "jest" "test:unit": "jest --coverage",
"test:cy": "cypress run",
"merge-coverage": "node scripts/merge-coverage.cjs",
"coverage": "pnpm run test:unit && pnpm run test:components && pnpm run merge-coverage"
}, },
"dependencies": { "dependencies": {
"@appflowyinc/client-api-wasm": "0.0.3", "@appflowyinc/client-api-wasm": "0.0.3",
@ -86,6 +89,7 @@
"slate": "^0.101.4", "slate": "^0.101.4",
"slate-history": "^0.100.0", "slate-history": "^0.100.0",
"slate-react": "^0.101.3", "slate-react": "^0.101.3",
"smooth-scroll-into-view-if-needed": "^2.0.2",
"ts-results": "^3.3.0", "ts-results": "^3.3.0",
"unsplash-js": "^7.0.19", "unsplash-js": "^7.0.19",
"utf8": "^3.0.0", "utf8": "^3.0.0",
@ -95,6 +99,9 @@
"yjs": "^13.6.14" "yjs": "^13.6.14"
}, },
"devDependencies": { "devDependencies": {
"@cypress/code-coverage": "^3.12.39",
"@istanbuljs/nyc-config-babel": "^3.0.0",
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@svgr/plugin-svgo": "^8.0.1", "@svgr/plugin-svgo": "^8.0.1",
"@tauri-apps/cli": "^1.5.11", "@tauri-apps/cli": "^1.5.11",
"@types/google-protobuf": "^3.15.12", "@types/google-protobuf": "^3.15.12",
@ -132,7 +139,9 @@
"eslint-plugin-react": "^7.32.2", "eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6", "eslint-plugin-react-refresh": "^0.4.6",
"istanbul-lib-coverage": "^3.2.2",
"jest-environment-jsdom": "^29.6.2", "jest-environment-jsdom": "^29.6.2",
"nyc": "^15.1.0",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"prettier": "2.8.4", "prettier": "2.8.4",
"prettier-plugin-tailwindcss": "^0.2.2", "prettier-plugin-tailwindcss": "^0.2.2",
@ -147,6 +156,7 @@
"vite": "^5.2.0", "vite": "^5.2.0",
"vite-plugin-compression2": "^1.0.0", "vite-plugin-compression2": "^1.0.0",
"vite-plugin-importer": "^0.2.5", "vite-plugin-importer": "^0.2.5",
"vite-plugin-istanbul": "^6.0.2",
"vite-plugin-svgr": "^3.2.0", "vite-plugin-svgr": "^3.2.0",
"vite-plugin-terminal": "^1.2.0", "vite-plugin-terminal": "^1.2.0",
"vite-plugin-total-bundle-size": "^1.0.7" "vite-plugin-total-bundle-size": "^1.0.7"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,31 @@
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const jestCoverageFile = path.join(__dirname, '../coverage/jest/coverage-final.json');
const cypressCoverageFile = path.join(__dirname, '../coverage/cypress/coverage-final.json');
// const cypressComponentCoverageFile = path.join(__dirname, '../coverage/cypress-component/coverage-final.json');
const nycOutputDir = path.join(__dirname, '../coverage/.nyc_output');
// Ensure .nyc_output directory exists
if (!fs.existsSync(nycOutputDir)) {
fs.mkdirSync(nycOutputDir, { recursive: true });
}
// Copy Jest coverage file
fs.copyFileSync(jestCoverageFile, path.join(nycOutputDir, 'jest-coverage.json'));
// Copy Cypress E2E coverage file
fs.copyFileSync(cypressCoverageFile, path.join(nycOutputDir, 'cypress-coverage.json'));
// Copy Cypress Component coverage file
// fs.copyFileSync(cypressComponentCoverageFile, path.join(nycOutputDir, 'cypress-component-coverage.json'));
// Merge coverage files
execSync('nyc merge ./coverage/.nyc_output ./coverage/merged/coverage-final.json', { stdio: 'inherit' });
// Generate final merged report
execSync('nyc report --reporter=html --reporter=text-summary --report-dir=coverage/merged --temp-dir=coverage/.nyc_output', { stdio: 'inherit' });
console.log(`Merged coverage report written to coverage/merged`);
const GITHUB_STEP_SUMMARY = process.env.GITHUB_STEP_SUMMARY;
if (GITHUB_STEP_SUMMARY) {
const coverageSummary = execSync('nyc report --reporter=html --reporter=text-summary --report-dir=coverage/merged --temp-dir=coverage/.nyc_output').toString();
fs.appendFileSync(GITHUB_STEP_SUMMARY, `### Coverage Report\n\`\`\`\n${coverageSummary}\n\`\`\`\n`);
}

View File

@ -0,0 +1,661 @@
import {
NumberFilterCondition,
TextFilterCondition,
CheckboxFilterCondition,
ChecklistFilterCondition,
SelectOptionFilterCondition,
Row,
} from '@/application/database-yjs';
import { withTestingData } from '@/application/database-yjs/__tests__/withTestingData';
import {
withCheckboxFilter,
withChecklistFilter,
withDateTimeFilter,
withMultiSelectOptionFilter,
withNumberFilter,
withRichTextFilter,
withSingleSelectOptionFilter,
withUrlFilter,
} from '@/application/database-yjs/__tests__/withTestingFilters';
import { withTestingRows } from '@/application/database-yjs/__tests__/withTestingRows';
import {
textFilterCheck,
numberFilterCheck,
checkboxFilterCheck,
checklistFilterCheck,
selectOptionFilterCheck,
filterBy,
} from '../filter';
import { expect } from '@jest/globals';
describe('Text filter check', () => {
const text = 'Hello, world!';
it('should return true for TextIs condition', () => {
const condition = TextFilterCondition.TextIs;
const content = 'Hello, world!';
const result = textFilterCheck(text, content, condition);
expect(result).toBe(true);
});
it('should return false for TextIs condition', () => {
const condition = TextFilterCondition.TextIs;
const content = 'Hello, world';
const result = textFilterCheck(text, content, condition);
expect(result).toBe(false);
});
it('should return true for TextIsNot condition', () => {
const condition = TextFilterCondition.TextIsNot;
const content = 'Hello, world';
const result = textFilterCheck(text, content, condition);
expect(result).toBe(true);
});
it('should return false for TextIsNot condition', () => {
const condition = TextFilterCondition.TextIsNot;
const content = 'Hello, world!';
const result = textFilterCheck(text, content, condition);
expect(result).toBe(false);
});
it('should return true for TextContains condition', () => {
const condition = TextFilterCondition.TextContains;
const content = 'world';
const result = textFilterCheck(text, content, condition);
expect(result).toBe(true);
});
it('should return false for TextContains condition', () => {
const condition = TextFilterCondition.TextContains;
const content = 'planet';
const result = textFilterCheck(text, content, condition);
expect(result).toBe(false);
});
it('should return true for TextDoesNotContain condition', () => {
const condition = TextFilterCondition.TextDoesNotContain;
const content = 'planet';
const result = textFilterCheck(text, content, condition);
expect(result).toBe(true);
});
it('should return false for TextDoesNotContain condition', () => {
const condition = TextFilterCondition.TextDoesNotContain;
const content = 'world';
const result = textFilterCheck(text, content, condition);
expect(result).toBe(false);
});
it('should return true for TextIsEmpty condition', () => {
const condition = TextFilterCondition.TextIsEmpty;
const text = '';
const result = textFilterCheck(text, '', condition);
expect(result).toBe(true);
});
it('should return false for TextIsEmpty condition', () => {
const condition = TextFilterCondition.TextIsEmpty;
const text = 'Hello, world!';
const result = textFilterCheck(text, '', condition);
expect(result).toBe(false);
});
it('should return true for TextIsNotEmpty condition', () => {
const condition = TextFilterCondition.TextIsNotEmpty;
const text = 'Hello, world!';
const result = textFilterCheck(text, '', condition);
expect(result).toBe(true);
});
it('should return false for TextIsNotEmpty condition', () => {
const condition = TextFilterCondition.TextIsNotEmpty;
const text = '';
const result = textFilterCheck(text, '', condition);
expect(result).toBe(false);
});
it('should return false for unknown condition', () => {
const condition = 42;
const content = 'Hello, world!';
const result = textFilterCheck(text, content, condition);
expect(result).toBe(false);
});
});
describe('Number filter check', () => {
const num = '42';
it('should return true for Equal condition', () => {
const condition = NumberFilterCondition.Equal;
const content = '42';
const result = numberFilterCheck(num, content, condition);
expect(result).toBe(true);
});
it('should return false for Equal condition', () => {
const condition = NumberFilterCondition.Equal;
const content = '43';
const result = numberFilterCheck(num, content, condition);
expect(result).toBe(false);
});
it('should return true for NotEqual condition', () => {
const condition = NumberFilterCondition.NotEqual;
const content = '43';
const result = numberFilterCheck(num, content, condition);
expect(result).toBe(true);
});
it('should return false for NotEqual condition', () => {
const condition = NumberFilterCondition.NotEqual;
const content = '42';
const result = numberFilterCheck(num, content, condition);
expect(result).toBe(false);
});
it('should return true for GreaterThan condition', () => {
const condition = NumberFilterCondition.GreaterThan;
const content = '41';
const result = numberFilterCheck(num, content, condition);
expect(result).toBe(true);
});
it('should return false for GreaterThan condition', () => {
const condition = NumberFilterCondition.GreaterThan;
const content = '42';
const result = numberFilterCheck(num, content, condition);
expect(result).toBe(false);
});
it('should return true for GreaterThanOrEqualTo condition', () => {
const condition = NumberFilterCondition.GreaterThanOrEqualTo;
const content = '42';
const result = numberFilterCheck(num, content, condition);
expect(result).toBe(true);
});
it('should return false for GreaterThanOrEqualTo condition', () => {
const condition = NumberFilterCondition.GreaterThanOrEqualTo;
const content = '43';
const result = numberFilterCheck(num, content, condition);
expect(result).toBe(false);
});
it('should return true for LessThan condition', () => {
const condition = NumberFilterCondition.LessThan;
const content = '43';
const result = numberFilterCheck(num, content, condition);
expect(result).toBe(true);
});
it('should return false for LessThan condition', () => {
const condition = NumberFilterCondition.LessThan;
const content = '42';
const result = numberFilterCheck(num, content, condition);
expect(result).toBe(false);
});
it('should return true for LessThanOrEqualTo condition', () => {
const condition = NumberFilterCondition.LessThanOrEqualTo;
const content = '42';
const result = numberFilterCheck(num, content, condition);
expect(result).toBe(true);
});
it('should return false for LessThanOrEqualTo condition', () => {
const condition = NumberFilterCondition.LessThanOrEqualTo;
const content = '41';
const result = numberFilterCheck(num, content, condition);
expect(result).toBe(false);
});
it('should return true for NumberIsEmpty condition', () => {
const condition = NumberFilterCondition.NumberIsEmpty;
const result = numberFilterCheck('', '', condition);
expect(result).toBe(true);
});
it('should return false for NumberIsEmpty condition', () => {
const condition = NumberFilterCondition.NumberIsEmpty;
const num = '42';
const result = numberFilterCheck(num, '', condition);
expect(result).toBe(false);
});
it('should return true for NumberIsNotEmpty condition', () => {
const condition = NumberFilterCondition.NumberIsNotEmpty;
const num = '42';
const result = numberFilterCheck(num, '', condition);
expect(result).toBe(true);
});
it('should return false for NumberIsNotEmpty condition', () => {
const condition = NumberFilterCondition.NumberIsNotEmpty;
const num = '';
const result = numberFilterCheck(num, '', condition);
expect(result).toBe(false);
});
it('should return false for unknown condition', () => {
const condition = 42;
const content = '42';
const result = numberFilterCheck(num, content, condition);
expect(result).toBe(false);
});
});
describe('Checkbox filter check', () => {
it('should return true for IsChecked condition', () => {
const condition = CheckboxFilterCondition.IsChecked;
const data = 'Yes';
const result = checkboxFilterCheck(data, condition);
expect(result).toBe(true);
});
it('should return false for IsChecked condition', () => {
const condition = CheckboxFilterCondition.IsChecked;
const data = 'No';
const result = checkboxFilterCheck(data, condition);
expect(result).toBe(false);
});
it('should return true for IsUnChecked condition', () => {
const condition = CheckboxFilterCondition.IsUnChecked;
const data = 'No';
const result = checkboxFilterCheck(data, condition);
expect(result).toBe(true);
});
it('should return false for IsUnChecked condition', () => {
const condition = CheckboxFilterCondition.IsUnChecked;
const data = 'Yes';
const result = checkboxFilterCheck(data, condition);
expect(result).toBe(false);
});
it('should return false for unknown condition', () => {
const condition = 42;
const data = 'Yes';
const result = checkboxFilterCheck(data, condition);
expect(result).toBe(false);
});
});
describe('Checklist filter check', () => {
it('should return true for IsComplete condition', () => {
const condition = ChecklistFilterCondition.IsComplete;
const data = JSON.stringify({
options: [
{ id: '1', name: 'Option 1' },
{ id: '2', name: 'Option 2' },
],
selected_option_ids: ['1', '2'],
});
const result = checklistFilterCheck(data, '', condition);
expect(result).toBe(true);
});
it('should return false for IsComplete condition', () => {
const condition = ChecklistFilterCondition.IsComplete;
const data = JSON.stringify({
options: [
{ id: '1', name: 'Option 1' },
{ id: '2', name: 'Option 2' },
],
selected_option_ids: ['1'],
});
const result = checklistFilterCheck(data, '', condition);
expect(result).toBe(false);
});
it('should return false for unknown condition', () => {
const condition = 42;
const data = JSON.stringify({
options: [
{ id: '1', name: 'Option 1' },
{ id: '2', name: 'Option 2' },
],
selected_option_ids: ['1', '2'],
});
const result = checklistFilterCheck(data, '', condition);
expect(result).toBe(false);
});
});
describe('SelectOption filter check', () => {
it('should return true for OptionIs condition', () => {
const condition = SelectOptionFilterCondition.OptionIs;
const content = '1';
const data = '1,2';
const result = selectOptionFilterCheck(data, content, condition);
expect(result).toBe(true);
});
it('should return false for OptionIs condition', () => {
const condition = SelectOptionFilterCondition.OptionIs;
const content = '3';
const data = '1,2';
const result = selectOptionFilterCheck(data, content, condition);
expect(result).toBe(false);
});
it('should return true for OptionIsNot condition', () => {
const condition = SelectOptionFilterCondition.OptionIsNot;
const content = '3';
const data = '1,2';
const result = selectOptionFilterCheck(data, content, condition);
expect(result).toBe(true);
});
it('should return false for OptionIsNot condition', () => {
const condition = SelectOptionFilterCondition.OptionIsNot;
const content = '1';
const data = '1,2';
const result = selectOptionFilterCheck(data, content, condition);
expect(result).toBe(false);
});
it('should return true for OptionContains condition', () => {
const condition = SelectOptionFilterCondition.OptionContains;
const content = '1,3';
const data = '1,2,3';
const result = selectOptionFilterCheck(data, content, condition);
expect(result).toBe(true);
});
it('should return false for OptionContains condition', () => {
const condition = SelectOptionFilterCondition.OptionContains;
const content = '4';
const data = '1,2,3';
const result = selectOptionFilterCheck(data, content, condition);
expect(result).toBe(false);
});
it('should return true for OptionDoesNotContain condition', () => {
const condition = SelectOptionFilterCondition.OptionDoesNotContain;
const content = '4,5';
const data = '1,2,3';
const result = selectOptionFilterCheck(data, content, condition);
expect(result).toBe(true);
});
it('should return false for OptionDoesNotContain condition', () => {
const condition = SelectOptionFilterCondition.OptionDoesNotContain;
const content = '1,3';
const data = '1,2,3';
const result = selectOptionFilterCheck(data, content, condition);
expect(result).toBe(false);
});
it('should return true for OptionIsEmpty condition', () => {
const condition = SelectOptionFilterCondition.OptionIsEmpty;
const data = '';
const result = selectOptionFilterCheck(data, '', condition);
expect(result).toBe(true);
});
it('should return false for OptionIsEmpty condition', () => {
const condition = SelectOptionFilterCondition.OptionIsEmpty;
const data = '1,2';
const result = selectOptionFilterCheck(data, '', condition);
expect(result).toBe(false);
});
it('should return true for OptionIsNotEmpty condition', () => {
const condition = SelectOptionFilterCondition.OptionIsNotEmpty;
const data = '1,2';
const result = selectOptionFilterCheck(data, '', condition);
expect(result).toBe(true);
});
it('should return false for OptionIsNotEmpty condition', () => {
const condition = SelectOptionFilterCondition.OptionIsNotEmpty;
const data = '';
const result = selectOptionFilterCheck(data, '', condition);
expect(result).toBe(false);
});
it('should return false for unknown condition', () => {
const condition = 42;
const content = '1';
const data = '1,2';
const result = selectOptionFilterCheck(data, content, condition);
expect(result).toBe(false);
});
});
describe('Database filterBy', () => {
let rows: Row[];
beforeEach(() => {
rows = withTestingRows();
});
it('should return all rows for empty filter', () => {
const { filters, fields, rowMap } = withTestingData();
const result = filterBy(rows, filters, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(result).toBe('1,2,3,4,5,6,7,8,9,10');
});
it('should return rows that match text filter', () => {
const { filters, fields, rowMap } = withTestingData();
const filter = withRichTextFilter();
filters.push([filter]);
const result = filterBy(rows, filters, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(result).toBe('1,5');
});
it('should return rows that match number filter', () => {
const { filters, fields, rowMap } = withTestingData();
const filter = withNumberFilter();
filters.push([filter]);
const result = filterBy(rows, filters, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(result).toBe('4,5,6,7,8,9,10');
});
it('should return rows that match checkbox filter', () => {
const { filters, fields, rowMap } = withTestingData();
const filter = withCheckboxFilter();
filters.push([filter]);
const result = filterBy(rows, filters, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(result).toBe('2,4,6,8,10');
});
it('should return rows that match checklist filter', () => {
const { filters, fields, rowMap } = withTestingData();
const filter = withChecklistFilter();
filters.push([filter]);
const result = filterBy(rows, filters, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(result).toBe('1,2,4,5,6,7,8,10');
});
it('should return rows that match multiple filters', () => {
const { filters, fields, rowMap } = withTestingData();
const filter1 = withRichTextFilter();
const filter2 = withNumberFilter();
filters.push([filter1, filter2]);
const result = filterBy(rows, filters, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(result).toBe('5');
});
it('should return rows that match url filter', () => {
const { filters, fields, rowMap } = withTestingData();
const filter = withUrlFilter();
filters.push([filter]);
const result = filterBy(rows, filters, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(result).toBe('4');
});
it('should return rows that match date filter', () => {
const { filters, fields, rowMap } = withTestingData();
const filter = withDateTimeFilter();
filters.push([filter]);
const result = filterBy(rows, filters, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(result).toBe('1,2,3,4,5,6,7,8,9,10');
});
it('should return rows that match select option filter', () => {
const { filters, fields, rowMap } = withTestingData();
const filter = withSingleSelectOptionFilter();
filters.push([filter]);
const result = filterBy(rows, filters, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(result).toBe('2,5,8');
});
it('should return rows that match multi select option filter', () => {
const { filters, fields, rowMap } = withTestingData();
const filter = withMultiSelectOptionFilter();
filters.push([filter]);
const result = filterBy(rows, filters, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(result).toBe('1,2,3,5,6,7,8,9');
});
it('should return rows that match multiple filters', () => {
const { filters, fields, rowMap } = withTestingData();
const filter1 = withNumberFilter();
const filter2 = withChecklistFilter();
filters.push([filter1, filter2]);
const result = filterBy(rows, filters, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(result).toBe('4,5,6,7,8,10');
});
it('should return empty array for all filters', () => {
const { filters, fields, rowMap } = withTestingData();
const filter1 = withNumberFilter();
const filter2 = withChecklistFilter();
const filter3 = withRichTextFilter();
const filter4 = withCheckboxFilter();
const filter5 = withSingleSelectOptionFilter();
const filter6 = withMultiSelectOptionFilter();
const filter7 = withUrlFilter();
const filter8 = withDateTimeFilter();
filters.push([filter1, filter2, filter3, filter4, filter5, filter6, filter7, filter8]);
const result = filterBy(rows, filters, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(result).toBe('');
});
});

View File

@ -0,0 +1,40 @@
{
"filter_text_field": {
"field_id": "text_field",
"condition": 2,
"content": "w"
},
"filter_number_field": {
"field_id": "number_field",
"condition": 2,
"content": 1000
},
"filter_date_field": {
"field_id": "date_field",
"condition": 1,
"content": 1685798400000
},
"filter_checkbox_field": {
"field_id": "checkbox_field",
"condition": 1
},
"filter_checklist_field": {
"field_id": "checklist_field",
"condition": 1
},
"filter_url_field": {
"field_id": "url_field",
"condition": 0,
"content": "https://example.com/4"
},
"filter_single_select_field": {
"field_id": "single_select_field",
"condition": 0,
"content": "2"
},
"filter_multi_select_field": {
"field_id": "multi_select_field",
"condition": 2,
"content": "1,3"
}
}

View File

@ -0,0 +1,412 @@
[
{
"id": "1",
"cells": {
"text_field": {
"id": "text_field",
"data": "Hello world"
},
"number_field": {
"id": "number_field",
"data": 123
},
"checkbox_field": {
"id": "checkbox_field",
"data": "Yes"
},
"date_field": {
"id": "date_field",
"data": 1685539200000,
"end_timestamp": 1685625600000,
"include_time": true,
"is_range": false,
"reminder_id": "rem1"
},
"url_field": {
"id": "url_field",
"data": "https://example.com/1"
},
"single_select_field": {
"id": "single_select_field",
"data": "1"
},
"multi_select_field": {
"id": "multi_select_field",
"data": "1,2"
},
"checklist_field": {
"id": "checklist_field",
"data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"1\"]}"
}
}
},
{
"id": "2",
"cells": {
"text_field": {
"id": "text_field",
"data": "Good morning"
},
"number_field": {
"id": "number_field",
"data": 456
},
"checkbox_field": {
"id": "checkbox_field",
"data": "No"
},
"date_field": {
"id": "date_field",
"data": 1685625600000,
"end_timestamp": 1685712000000,
"include_time": false,
"is_range": true,
"reminder_id": "rem2"
},
"url_field": {
"id": "url_field",
"data": "https://example.com/2"
},
"single_select_field": {
"id": "single_select_field",
"data": "2"
},
"multi_select_field": {
"id": "multi_select_field",
"data": "2,3"
},
"checklist_field": {
"id": "checklist_field",
"data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"2\"]}"
}
}
},
{
"id": "3",
"cells": {
"text_field": {
"id": "text_field",
"data": "Good night"
},
"number_field": {
"id": "number_field",
"data": 789
},
"checkbox_field": {
"id": "checkbox_field",
"data": "Yes"
},
"date_field": {
"id": "date_field",
"data": 1685712000000,
"end_timestamp": 1685798400000,
"include_time": true,
"is_range": false,
"reminder_id": "rem3"
},
"url_field": {
"id": "url_field",
"data": "https://example.com/3"
},
"single_select_field": {
"id": "single_select_field",
"data": "3"
},
"multi_select_field": {
"id": "multi_select_field",
"data": "1,3"
},
"checklist_field": {
"id": "checklist_field",
"data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"1\",\"2\"]}"
}
}
},
{
"id": "4",
"cells": {
"text_field": {
"id": "text_field",
"data": "Happy day"
},
"number_field": {
"id": "number_field",
"data": 1011
},
"checkbox_field": {
"id": "checkbox_field",
"data": "No"
},
"date_field": {
"id": "date_field",
"data": 1685798400000,
"end_timestamp": 1685884800000,
"include_time": false,
"is_range": true,
"reminder_id": "rem4"
},
"url_field": {
"id": "url_field",
"data": "https://example.com/4"
},
"single_select_field": {
"id": "single_select_field",
"data": "1"
},
"multi_select_field": {
"id": "multi_select_field",
"data": "2"
},
"checklist_field": {
"id": "checklist_field",
"data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[]}"
}
}
},
{
"id": "5",
"cells": {
"text_field": {
"id": "text_field",
"data": "Sunny weather"
},
"number_field": {
"id": "number_field",
"data": 1213
},
"checkbox_field": {
"id": "checkbox_field",
"data": "Yes"
},
"date_field": {
"id": "date_field",
"data": 1685884800000,
"end_timestamp": 1685971200000,
"include_time": true,
"is_range": false,
"reminder_id": "rem5"
},
"url_field": {
"id": "url_field",
"data": "https://example.com/5"
},
"single_select_field": {
"id": "single_select_field",
"data": "2"
},
"multi_select_field": {
"id": "multi_select_field",
"data": "1,2,3"
},
"checklist_field": {
"id": "checklist_field",
"data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"1\"]}"
}
}
},
{
"id": "6",
"cells": {
"text_field": {
"id": "text_field",
"data": "Rainy day"
},
"number_field": {
"id": "number_field",
"data": 1415
},
"checkbox_field": {
"id": "checkbox_field",
"data": "No"
},
"date_field": {
"id": "date_field",
"data": 1685971200000,
"end_timestamp": 1686057600000,
"include_time": false,
"is_range": true,
"reminder_id": "rem6"
},
"url_field": {
"id": "url_field",
"data": "https://example.com/6"
},
"single_select_field": {
"id": "single_select_field",
"data": "3"
},
"multi_select_field": {
"id": "multi_select_field",
"data": "1,3"
},
"checklist_field": {
"id": "checklist_field",
"data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"2\"]}"
}
}
},
{
"id": "7",
"cells": {
"text_field": {
"id": "text_field",
"data": "Winter is coming"
},
"number_field": {
"id": "number_field",
"data": 1617
},
"checkbox_field": {
"id": "checkbox_field",
"data": "Yes"
},
"date_field": {
"id": "date_field",
"data": 1686057600000,
"end_timestamp": 1686144000000,
"include_time": true,
"is_range": false,
"reminder_id": "rem7"
},
"url_field": {
"id": "url_field",
"data": "https://example.com/7"
},
"single_select_field": {
"id": "single_select_field",
"data": "1"
},
"multi_select_field": {
"id": "multi_select_field",
"data": "1,2"
},
"checklist_field": {
"id": "checklist_field",
"data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"1\"]}"
}
}
},
{
"id": "8",
"cells": {
"text_field": {
"id": "text_field",
"data": "Summer vibes"
},
"number_field": {
"id": "number_field",
"data": 1819
},
"checkbox_field": {
"id": "checkbox_field",
"data": "No"
},
"date_field": {
"id": "date_field",
"data": 1686144000000,
"end_timestamp": 1686230400000,
"include_time": false,
"is_range": true,
"reminder_id": "rem8"
},
"url_field": {
"id": "url_field",
"data": "https://example.com/8"
},
"single_select_field": {
"id": "single_select_field",
"data": "2"
},
"multi_select_field": {
"id": "multi_select_field",
"data": "2,3"
},
"checklist_field": {
"id": "checklist_field",
"data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"2\"]}"
}
}
},
{
"id": "9",
"cells": {
"text_field": {
"id": "text_field",
"data": "Autumn leaves"
},
"number_field": {
"id": "number_field",
"data": 2021
},
"checkbox_field": {
"id": "checkbox_field",
"data": "Yes"
},
"date_field": {
"id": "date_field",
"data": 1686230400000,
"end_timestamp": 1686316800000,
"include_time": true,
"is_range": false,
"reminder_id": "rem9"
},
"url_field": {
"id": "url_field",
"data": "https://example.com/9"
},
"single_select_field": {
"id": "single_select_field",
"data": "3"
},
"multi_select_field": {
"id": "multi_select_field",
"data": "1,3"
},
"checklist_field": {
"id": "checklist_field",
"data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"1\",\"2\"]}"
}
}
},
{
"id": "10",
"cells": {
"text_field": {
"id": "text_field",
"data": "Spring blossoms"
},
"number_field": {
"id": "number_field",
"data": 2223
},
"checkbox_field": {
"id": "checkbox_field",
"data": "No"
},
"date_field": {
"id": "date_field",
"data": 1686316800000,
"end_timestamp": 1686403200000,
"include_time": false,
"is_range": true,
"reminder_id": "rem10"
},
"url_field": {
"id": "url_field",
"data": "https://example.com/10"
},
"single_select_field": {
"id": "single_select_field",
"data": "1"
},
"multi_select_field": {
"id": "multi_select_field",
"data": "2"
},
"checklist_field": {
"id": "checklist_field",
"data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[]}"
}
}
}
]

View File

@ -0,0 +1,82 @@
{
"sort_asc_text_field": {
"id": "sort_asc_text_field",
"field_id": "text_field",
"condition": "asc"
},
"sort_desc_text_field": {
"field_id": "text_field",
"condition": "desc",
"id": "sort_desc_text_field"
},
"sort_asc_number_field": {
"field_id": "number_field",
"condition": "asc",
"id": "sort_asc_number_field"
},
"sort_desc_number_field": {
"field_id": "number_field",
"condition": "desc",
"id": "sort_desc_number_field"
},
"sort_asc_date_field": {
"field_id": "date_field",
"condition": "asc",
"id": "sort_asc_date_field"
},
"sort_desc_date_field": {
"field_id": "date_field",
"condition": "desc",
"id": "sort_desc_date_field"
},
"sort_asc_checkbox_field": {
"field_id": "checkbox_field",
"condition": "asc",
"id": "sort_asc_checkbox_field"
},
"sort_desc_checkbox_field": {
"field_id": "checkbox_field",
"condition": "desc",
"id": "sort_desc_checkbox_field"
},
"sort_asc_checklist_field": {
"field_id": "checklist_field",
"condition": "asc",
"id": "sort_asc_checklist_field"
},
"sort_desc_checklist_field": {
"field_id": "checklist_field",
"condition": "desc",
"id": "sort_desc_checklist_field"
},
"sort_asc_single_select_field": {
"field_id": "single_select_field",
"condition": "asc",
"id": "sort_asc_single_select_field"
},
"sort_desc_single_select_field": {
"field_id": "single_select_field",
"condition": "desc",
"id": "sort_desc_single_select_field"
},
"sort_asc_multi_select_field": {
"field_id": "multi_select_field",
"condition": "asc",
"id": "sort_asc_multi_select_field"
},
"sort_desc_multi_select_field": {
"field_id": "multi_select_field",
"condition": "desc",
"id": "sort_desc_multi_select_field"
},
"sort_asc_url_field": {
"field_id": "url_field",
"condition": "asc",
"id": "sort_asc_url_field"
},
"sort_desc_url_field": {
"field_id": "url_field",
"condition": "desc",
"id": "sort_desc_url_field"
}
}

View File

@ -0,0 +1,98 @@
import { Row } from '@/application/database-yjs';
import { withTestingData } from '@/application/database-yjs/__tests__/withTestingData';
import { withTestingRows } from '@/application/database-yjs/__tests__/withTestingRows';
import { expect } from '@jest/globals';
import { groupByField } from '../group';
describe('Database group', () => {
let rows: Row[];
beforeEach(() => {
rows = withTestingRows();
});
it('should return undefined if field is not select option', () => {
const { fields, rowMap } = withTestingData();
expect(groupByField(rows, rowMap, fields.get('text_field'))).toBeUndefined();
expect(groupByField(rows, rowMap, fields.get('number_field'))).toBeUndefined();
expect(groupByField(rows, rowMap, fields.get('checkbox_field'))).toBeUndefined();
expect(groupByField(rows, rowMap, fields.get('checklist_field'))).toBeUndefined();
});
it('should group by select option field', () => {
const { fields, rowMap } = withTestingData();
const field = fields.get('single_select_field');
const result = groupByField(rows, rowMap, field);
const expectRes = new Map([
[
'1',
[
{ id: '1', height: 37 },
{ id: '4', height: 37 },
{ id: '7', height: 37 },
{ id: '10', height: 37 },
],
],
[
'2',
[
{ id: '2', height: 37 },
{ id: '5', height: 37 },
{ id: '8', height: 37 },
],
],
[
'3',
[
{ id: '3', height: 37 },
{ id: '6', height: 37 },
{ id: '9', height: 37 },
],
],
]);
expect(result).toEqual(expectRes);
});
it('should group by multi select option field', () => {
const { fields, rowMap } = withTestingData();
const field = fields.get('multi_select_field');
const result = groupByField(rows, rowMap, field);
const expectRes = new Map([
[
'1',
[
{ id: '1', height: 37 },
{ id: '3', height: 37 },
{ id: '5', height: 37 },
{ id: '6', height: 37 },
{ id: '7', height: 37 },
{ id: '9', height: 37 },
],
],
[
'2',
[
{ id: '1', height: 37 },
{ id: '2', height: 37 },
{ id: '4', height: 37 },
{ id: '5', height: 37 },
{ id: '7', height: 37 },
{ id: '8', height: 37 },
{ id: '10', height: 37 },
],
],
[
'3',
[
{ id: '2', height: 37 },
{ id: '3', height: 37 },
{ id: '5', height: 37 },
{ id: '6', height: 37 },
{ id: '8', height: 37 },
{ id: '9', height: 37 },
],
],
]);
expect(result).toEqual(expectRes);
});
});

View File

@ -0,0 +1,314 @@
import { Row } from '@/application/database-yjs';
import { withTestingData } from '@/application/database-yjs/__tests__/withTestingData';
import { withTestingRows } from '@/application/database-yjs/__tests__/withTestingRows';
import {
withCheckboxSort,
withChecklistSort,
withDateTimeSort,
withMultiSelectOptionSort,
withNumberSort,
withRichTextSort,
withSingleSelectOptionSort,
withUrlSort,
} from '@/application/database-yjs/__tests__/withTestingSorts';
import {
withCheckboxTestingField,
withDateTimeTestingField,
withNumberTestingField,
withRichTextTestingField,
withSelectOptionTestingField,
withURLTestingField,
withChecklistTestingField,
} from './withTestingField';
import { sortBy, parseCellDataForSort } from '../sort';
import * as Y from 'yjs';
import { expect } from '@jest/globals';
describe('parseCellDataForSort', () => {
it('should parse data correctly based on field type', () => {
const doc = new Y.Doc();
const field = withNumberTestingField();
doc.getMap().set('field', field);
const data = 42;
const result = parseCellDataForSort(field, data);
expect(result).toEqual(data);
});
it('should return default value for empty rich text', () => {
const doc = new Y.Doc();
const field = withRichTextTestingField();
doc.getMap().set('field', field);
const data = '';
const result = parseCellDataForSort(field, data);
expect(result).toEqual('\uFFFF');
});
it('should return default value for empty URL', () => {
const doc = new Y.Doc();
const field = withURLTestingField();
doc.getMap().set('field', field);
const data = '';
const result = parseCellDataForSort(field, data);
expect(result).toBe('\uFFFF');
});
it('should return data for non-empty rich text', () => {
const doc = new Y.Doc();
const field = withRichTextTestingField();
doc.getMap().set('field', field);
const data = 'Hello, world!';
const result = parseCellDataForSort(field, data);
expect(result).toBe(data);
});
it('should parse checkbox data correctly', () => {
const doc = new Y.Doc();
const field = withCheckboxTestingField();
doc.getMap().set('field', field);
const data = 'Yes';
const result = parseCellDataForSort(field, data);
expect(result).toBe(true);
const noData = 'No';
const noResult = parseCellDataForSort(field, noData);
expect(noResult).toBe(false);
});
it('should parse DateTime data correctly', () => {
const doc = new Y.Doc();
const field = withDateTimeTestingField();
doc.getMap().set('field', field);
const data = '1633046400000';
const result = parseCellDataForSort(field, data);
expect(result).toBe(Number(data));
});
it('should parse SingleSelect data correctly', () => {
const doc = new Y.Doc();
const field = withSelectOptionTestingField();
doc.getMap().set('field', field);
const data = '1';
const result = parseCellDataForSort(field, data);
expect(result).toBe('Option 1');
});
it('should parse MultiSelect data correctly', () => {
const doc = new Y.Doc();
const field = withSelectOptionTestingField();
doc.getMap().set('field', field);
const data = '1,2';
const result = parseCellDataForSort(field, data);
expect(result).toBe('Option 1, Option 2');
});
it('should parse Checklist data correctly', () => {
const doc = new Y.Doc();
const field = withChecklistTestingField();
doc.getMap().set('field', field);
const data = '[]';
const result = parseCellDataForSort(field, data);
expect(result).toBe(0);
});
});
describe('Database sortBy', () => {
let rows: Row[];
beforeEach(() => {
rows = withTestingRows();
});
it('should sort by number field in ascending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withNumberSort();
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10');
});
it('should sort by number field in descending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withNumberSort(false);
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('10,9,8,7,6,5,4,3,2,1');
});
it('should sort by rich text field in ascending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withRichTextSort();
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('9,2,3,4,1,6,10,8,5,7');
});
it('should sort by rich text field in descending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withRichTextSort(false);
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('7,5,8,10,6,1,4,3,2,9');
});
it('should sort by url field in ascending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withUrlSort();
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('1,10,2,3,4,5,6,7,8,9');
});
it('should sort by url field in descending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withUrlSort(false);
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('9,8,7,6,5,4,3,2,10,1');
});
it('should sort by checkbox field in ascending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withCheckboxSort();
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('2,4,6,8,10,1,3,5,7,9');
});
it('should sort by checkbox field in descending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withCheckboxSort(false);
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('1,3,5,7,9,2,4,6,8,10');
});
it('should sort by DateTime field in ascending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withDateTimeSort();
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10');
});
it('should sort by DateTime field in descending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withDateTimeSort(false);
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('10,9,8,7,6,5,4,3,2,1');
});
it('should sort by SingleSelect field in ascending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withSingleSelectOptionSort();
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('1,4,7,10,2,5,8,3,6,9');
});
it('should sort by SingleSelect field in descending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withSingleSelectOptionSort(false);
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('3,6,9,2,5,8,1,4,7,10');
});
it('should sort by MultiSelect field in ascending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withMultiSelectOptionSort();
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('1,7,5,3,6,9,4,10,2,8');
});
it('should sort by MultiSelect field in descending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withMultiSelectOptionSort(false);
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('2,8,4,10,3,6,9,5,1,7');
});
it('should sort by Checklist field in ascending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withChecklistSort();
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('4,10,1,2,5,6,7,8,3,9');
});
it('should sort by Checklist field in descending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withChecklistSort(false);
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('3,9,1,2,5,6,7,8,4,10');
});
});

View File

@ -0,0 +1,31 @@
import { YDatabaseFields, YDatabaseFilters, YDatabaseSorts } from '@/application/collab.type';
import { withTestingFields } from '@/application/database-yjs/__tests__/withTestingField';
import { withTestingRowDataMap } from '@/application/database-yjs/__tests__/withTestingRows';
import * as Y from 'yjs';
export function withTestingData() {
const doc = new Y.Doc();
const sharedRoot = doc.getMap();
const fields = withTestingFields() as YDatabaseFields;
sharedRoot.set('fields', fields);
const rowMap = withTestingRowDataMap();
sharedRoot.set('rows', rowMap);
const sorts = new Y.Array() as YDatabaseSorts;
sharedRoot.set('sorts', sorts);
const filters = new Y.Array() as YDatabaseFilters;
sharedRoot.set('filters', filters);
return {
fields,
rowMap,
sorts,
filters,
};
}

View File

@ -0,0 +1,153 @@
import {
YDatabaseField,
YDatabaseFieldTypeOption,
YjsDatabaseKey,
YMapFieldTypeOption,
} from '@/application/collab.type';
import { FieldType, SelectOptionColor } from '@/application/database-yjs';
import * as Y from 'yjs';
export function withTestingFields() {
const fields = new Y.Map();
const textField = withRichTextTestingField();
fields.set('text_field', textField);
const numberField = withNumberTestingField();
fields.set('number_field', numberField);
const checkboxField = withCheckboxTestingField();
fields.set('checkbox_field', checkboxField);
const dateTimeField = withDateTimeTestingField();
fields.set('date_field', dateTimeField);
const singleSelectField = withSelectOptionTestingField();
fields.set('single_select_field', singleSelectField);
const multipleSelectField = withSelectOptionTestingField(true);
fields.set('multi_select_field', multipleSelectField);
const urlField = withURLTestingField();
fields.set('url_field', urlField);
const checklistField = withChecklistTestingField();
fields.set('checklist_field', checklistField);
return fields;
}
export function withRichTextTestingField() {
const field = new Y.Map() as YDatabaseField;
const now = Date.now().toString();
field.set(YjsDatabaseKey.name, 'Rich Text Field');
field.set(YjsDatabaseKey.id, 'text_field');
field.set(YjsDatabaseKey.type, String(FieldType.RichText));
field.set(YjsDatabaseKey.last_modified, now.valueOf());
return field;
}
export function withNumberTestingField() {
const field = new Y.Map() as YDatabaseField;
const now = Date.now().toString();
field.set(YjsDatabaseKey.name, 'Number Field');
field.set(YjsDatabaseKey.id, 'number_field');
field.set(YjsDatabaseKey.type, String(FieldType.Number));
field.set(YjsDatabaseKey.last_modified, now.valueOf());
return field;
}
export function withCheckboxTestingField() {
const field = new Y.Map() as YDatabaseField;
const now = Date.now().toString();
field.set(YjsDatabaseKey.name, 'Checkbox Field');
field.set(YjsDatabaseKey.id, 'checkbox_field');
field.set(YjsDatabaseKey.type, String(FieldType.Checkbox));
field.set(YjsDatabaseKey.last_modified, now.valueOf());
return field;
}
export function withDateTimeTestingField() {
const field = new Y.Map() as YDatabaseField;
const typeOption = new Y.Map() as YDatabaseFieldTypeOption;
const now = Date.now().toString();
field.set(YjsDatabaseKey.name, 'DateTime Field');
field.set(YjsDatabaseKey.id, 'date_field');
field.set(YjsDatabaseKey.type, String(FieldType.DateTime));
field.set(YjsDatabaseKey.last_modified, now.valueOf());
field.set(YjsDatabaseKey.type_option, typeOption);
const dateTypeOption = new Y.Map() as YMapFieldTypeOption;
typeOption.set(String(FieldType.DateTime), dateTypeOption);
dateTypeOption.set(YjsDatabaseKey.time_format, '0');
dateTypeOption.set(YjsDatabaseKey.date_format, '0');
return field;
}
export function withURLTestingField() {
const field = new Y.Map() as YDatabaseField;
const now = Date.now().toString();
field.set(YjsDatabaseKey.name, 'URL Field');
field.set(YjsDatabaseKey.id, 'url_field');
field.set(YjsDatabaseKey.type, String(FieldType.URL));
field.set(YjsDatabaseKey.last_modified, now.valueOf());
return field;
}
export function withSelectOptionTestingField(isMultiple = false) {
const field = new Y.Map() as YDatabaseField;
const typeOption = new Y.Map() as YDatabaseFieldTypeOption;
const now = Date.now().toString();
field.set(YjsDatabaseKey.name, 'Single Select Field');
field.set(YjsDatabaseKey.id, isMultiple ? 'multi_select_field' : 'single_select_field');
field.set(YjsDatabaseKey.type, String(FieldType.SingleSelect));
field.set(YjsDatabaseKey.last_modified, now.valueOf());
field.set(YjsDatabaseKey.type_option, typeOption);
const selectTypeOption = new Y.Map() as YMapFieldTypeOption;
typeOption.set(String(FieldType.SingleSelect), selectTypeOption);
selectTypeOption.set(
YjsDatabaseKey.content,
JSON.stringify({
disable_color: false,
options: [
{ id: '1', name: 'Option 1', color: SelectOptionColor.Purple },
{ id: '2', name: 'Option 2', color: SelectOptionColor.Pink },
{ id: '3', name: 'Option 3', color: SelectOptionColor.LightPink },
],
})
);
return field;
}
export function withChecklistTestingField() {
const field = new Y.Map() as YDatabaseField;
const now = Date.now().toString();
field.set(YjsDatabaseKey.name, 'Checklist Field');
field.set(YjsDatabaseKey.id, 'checklist_field');
field.set(YjsDatabaseKey.type, String(FieldType.Checklist));
field.set(YjsDatabaseKey.last_modified, now.valueOf());
return field;
}

View File

@ -0,0 +1,83 @@
import { YDatabaseFilter, YjsDatabaseKey } from '@/application/collab.type';
import * as Y from 'yjs';
import * as filtersJson from './fixtures/filters.json';
export function withRichTextFilter() {
const filter = new Y.Map() as YDatabaseFilter;
filter.set(YjsDatabaseKey.id, 'filter_text_field');
filter.set(YjsDatabaseKey.field_id, filtersJson.filter_text_field.field_id);
filter.set(YjsDatabaseKey.condition, filtersJson.filter_text_field.condition);
filter.set(YjsDatabaseKey.content, filtersJson.filter_text_field.content);
return filter;
}
export function withUrlFilter() {
const filter = new Y.Map() as YDatabaseFilter;
filter.set(YjsDatabaseKey.id, 'filter_url_field');
filter.set(YjsDatabaseKey.field_id, filtersJson.filter_url_field.field_id);
filter.set(YjsDatabaseKey.condition, filtersJson.filter_url_field.condition);
filter.set(YjsDatabaseKey.content, filtersJson.filter_url_field.content);
return filter;
}
export function withNumberFilter() {
const filter = new Y.Map() as YDatabaseFilter;
filter.set(YjsDatabaseKey.id, 'filter_number_field');
filter.set(YjsDatabaseKey.field_id, filtersJson.filter_number_field.field_id);
filter.set(YjsDatabaseKey.condition, filtersJson.filter_number_field.condition);
filter.set(YjsDatabaseKey.content, filtersJson.filter_number_field.content);
return filter;
}
export function withCheckboxFilter() {
const filter = new Y.Map() as YDatabaseFilter;
filter.set(YjsDatabaseKey.id, 'filter_checkbox_field');
filter.set(YjsDatabaseKey.field_id, filtersJson.filter_checkbox_field.field_id);
filter.set(YjsDatabaseKey.condition, filtersJson.filter_checkbox_field.condition);
filter.set(YjsDatabaseKey.content, '');
return filter;
}
export function withChecklistFilter() {
const filter = new Y.Map() as YDatabaseFilter;
filter.set(YjsDatabaseKey.id, 'filter_checklist_field');
filter.set(YjsDatabaseKey.field_id, filtersJson.filter_checklist_field.field_id);
filter.set(YjsDatabaseKey.condition, filtersJson.filter_checklist_field.condition);
filter.set(YjsDatabaseKey.content, '');
return filter;
}
export function withSingleSelectOptionFilter() {
const filter = new Y.Map() as YDatabaseFilter;
filter.set(YjsDatabaseKey.id, 'filter_single_select_field');
filter.set(YjsDatabaseKey.field_id, filtersJson.filter_single_select_field.field_id);
filter.set(YjsDatabaseKey.condition, filtersJson.filter_single_select_field.condition);
filter.set(YjsDatabaseKey.content, filtersJson.filter_single_select_field.content);
return filter;
}
export function withMultiSelectOptionFilter() {
const filter = new Y.Map() as YDatabaseFilter;
filter.set(YjsDatabaseKey.id, 'filter_multi_select_field');
filter.set(YjsDatabaseKey.field_id, filtersJson.filter_multi_select_field.field_id);
filter.set(YjsDatabaseKey.condition, filtersJson.filter_multi_select_field.condition);
filter.set(YjsDatabaseKey.content, filtersJson.filter_multi_select_field.content);
return filter;
}
export function withDateTimeFilter() {
const filter = new Y.Map() as YDatabaseFilter;
filter.set(YjsDatabaseKey.id, 'filter_date_field');
filter.set(YjsDatabaseKey.field_id, filtersJson.filter_date_field.field_id);
filter.set(YjsDatabaseKey.condition, filtersJson.filter_date_field.condition);
filter.set(YjsDatabaseKey.content, filtersJson.filter_date_field.content);
return filter;
}

View File

@ -0,0 +1,94 @@
import {
YDatabaseCell,
YDatabaseCells,
YDatabaseRow,
YDoc,
YjsDatabaseKey,
YjsEditorKey,
} from '@/application/collab.type';
import { FieldType, Row } from '@/application/database-yjs';
import * as Y from 'yjs';
import * as rowsJson from './fixtures/rows.json';
export function withTestingRows(): Row[] {
return rowsJson.map((row) => {
return {
id: row.id,
height: 37,
};
});
}
export function withTestingRowDataMap(): Y.Map<YDoc> {
const folder = new Y.Map();
const rows = withTestingRows();
rows.forEach((row, index) => {
const rowDoc = new Y.Doc();
const rowData = withTestingRowData(row.id, index);
rowDoc.getMap(YjsEditorKey.data_section).set(YjsEditorKey.database_row, rowData);
folder.set(row.id, rowDoc);
});
return folder as Y.Map<YDoc>;
}
export function withTestingRowData(id: string, index: number) {
const rowData = new Y.Map() as YDatabaseRow;
rowData.set(YjsDatabaseKey.id, id);
rowData.set(YjsDatabaseKey.height, 37);
const cells = new Y.Map() as YDatabaseCells;
const textFieldCell = withTestingCell(rowsJson[index].cells.text_field.data);
textFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.RichText));
cells.set('text_field', textFieldCell);
const numberFieldCell = withTestingCell(rowsJson[index].cells.number_field.data);
numberFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.Number));
cells.set('number_field', numberFieldCell);
const checkboxFieldCell = withTestingCell(rowsJson[index].cells.checkbox_field.data);
checkboxFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.Checkbox));
cells.set('checkbox_field', checkboxFieldCell);
const dateTimeFieldCell = withTestingCell(rowsJson[index].cells.date_field.data);
dateTimeFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.DateTime));
cells.set('date_field', dateTimeFieldCell);
const urlFieldCell = withTestingCell(rowsJson[index].cells.url_field.data);
urlFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.URL));
cells.set('url_field', urlFieldCell);
const singleSelectFieldCell = withTestingCell(rowsJson[index].cells.single_select_field.data);
singleSelectFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.SingleSelect));
cells.set('single_select_field', singleSelectFieldCell);
const multiSelectFieldCell = withTestingCell(rowsJson[index].cells.multi_select_field.data);
multiSelectFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.MultiSelect));
cells.set('multi_select_field', multiSelectFieldCell);
const checlistFieldCell = withTestingCell(rowsJson[index].cells.checklist_field.data);
checlistFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.Checklist));
cells.set('checklist_field', checlistFieldCell);
rowData.set(YjsDatabaseKey.cells, cells);
return rowData;
}
export function withTestingCell(cellData: string | number) {
const cell = new Y.Map() as YDatabaseCell;
cell.set(YjsDatabaseKey.data, cellData);
return cell;
}

View File

@ -0,0 +1,91 @@
import { YDatabaseSort, YjsDatabaseKey } from '@/application/collab.type';
import * as Y from 'yjs';
import * as sortsJson from './fixtures/sorts.json';
export function withRichTextSort(isAscending: boolean = true) {
const sort = new Y.Map() as YDatabaseSort;
const sortJSON = isAscending ? sortsJson.sort_asc_text_field : sortsJson.sort_desc_text_field;
sort.set(YjsDatabaseKey.id, sortJSON.id);
sort.set(YjsDatabaseKey.field_id, sortJSON.field_id);
sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1');
return sort;
}
export function withUrlSort(isAscending: boolean = true) {
const sort = new Y.Map() as YDatabaseSort;
const sortJSON = isAscending ? sortsJson.sort_asc_url_field : sortsJson.sort_desc_url_field;
sort.set(YjsDatabaseKey.id, sortJSON.id);
sort.set(YjsDatabaseKey.field_id, sortJSON.field_id);
sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1');
return sort;
}
export function withNumberSort(isAscending: boolean = true) {
const sort = new Y.Map() as YDatabaseSort;
const sortJSON = isAscending ? sortsJson.sort_asc_number_field : sortsJson.sort_desc_number_field;
sort.set(YjsDatabaseKey.id, sortJSON.id);
sort.set(YjsDatabaseKey.field_id, sortJSON.field_id);
sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1');
return sort;
}
export function withCheckboxSort(isAscending: boolean = true) {
const sort = new Y.Map() as YDatabaseSort;
const sortJSON = isAscending ? sortsJson.sort_asc_checkbox_field : sortsJson.sort_desc_checkbox_field;
sort.set(YjsDatabaseKey.id, sortJSON.id);
sort.set(YjsDatabaseKey.field_id, sortJSON.field_id);
sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1');
return sort;
}
export function withDateTimeSort(isAscending: boolean = true) {
const sort = new Y.Map() as YDatabaseSort;
const sortJSON = isAscending ? sortsJson.sort_asc_date_field : sortsJson.sort_desc_date_field;
sort.set(YjsDatabaseKey.id, sortJSON.id);
sort.set(YjsDatabaseKey.field_id, sortJSON.field_id);
sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1');
return sort;
}
export function withSingleSelectOptionSort(isAscending: boolean = true) {
const sort = new Y.Map() as YDatabaseSort;
const sortJSON = isAscending ? sortsJson.sort_asc_single_select_field : sortsJson.sort_desc_single_select_field;
sort.set(YjsDatabaseKey.id, sortJSON.id);
sort.set(YjsDatabaseKey.field_id, sortJSON.field_id);
sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1');
return sort;
}
export function withMultiSelectOptionSort(isAscending: boolean = true) {
const sort = new Y.Map() as YDatabaseSort;
const sortJSON = isAscending ? sortsJson.sort_asc_multi_select_field : sortsJson.sort_desc_multi_select_field;
sort.set(YjsDatabaseKey.id, sortJSON.id);
sort.set(YjsDatabaseKey.field_id, sortJSON.field_id);
sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1');
return sort;
}
export function withChecklistSort(isAscending: boolean = true) {
const sort = new Y.Map() as YDatabaseSort;
const sortJSON = isAscending ? sortsJson.sort_asc_checklist_field : sortsJson.sort_desc_checklist_field;
sort.set(YjsDatabaseKey.id, sortJSON.id);
sort.set(YjsDatabaseKey.field_id, sortJSON.field_id);
sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1');
return sort;
}

View File

@ -3,7 +3,7 @@ import { RowMetaKey } from '@/application/database-yjs/database.type';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { v5 as uuidv5, parse as uuidParse } from 'uuid'; import { v5 as uuidv5, parse as uuidParse } from 'uuid';
export const DEFAULT_ROW_HEIGHT = 37; export const DEFAULT_ROW_HEIGHT = 36;
export const MIN_COLUMN_WIDTH = 100; export const MIN_COLUMN_WIDTH = 100;
export const getCell = (rowId: string, fieldId: string, rowMetas: Y.Map<YDoc>) => { export const getCell = (rowId: string, fieldId: string, rowMetas: Y.Map<YDoc>) => {

View File

@ -64,7 +64,7 @@ export const useDatabaseView = () => {
const database = useDatabase(); const database = useDatabase();
const viewId = useViewId(); const viewId = useViewId();
return viewId ? database.get(YjsDatabaseKey.views)?.get(viewId) : undefined; return viewId ? database?.get(YjsDatabaseKey.views)?.get(viewId) : undefined;
}; };
export function useDatabaseFields() { export function useDatabaseFields() {

View File

@ -141,6 +141,18 @@ export function textFilterCheck(data: string, content: string, condition: TextFi
} }
export function numberFilterCheck(data: string, content: string, condition: number) { export function numberFilterCheck(data: string, content: string, condition: number) {
if (isNaN(Number(data)) || isNaN(Number(content)) || data === '' || content === '') {
if (condition === NumberFilterCondition.NumberIsEmpty && data === '') {
return true;
}
if (condition === NumberFilterCondition.NumberIsNotEmpty && data !== '') {
return true;
}
return false;
}
const decimal = new Decimal(data).toNumber(); const decimal = new Decimal(data).toNumber();
const filterDecimal = new Decimal(content).toNumber(); const filterDecimal = new Decimal(content).toNumber();
@ -188,6 +200,14 @@ export function checklistFilterCheck(data: string, content: string, condition: n
} }
export function selectOptionFilterCheck(data: string, content: string, condition: number) { export function selectOptionFilterCheck(data: string, content: string, condition: number) {
if (SelectOptionFilterCondition.OptionIsEmpty === condition) {
return data === '';
}
if (SelectOptionFilterCondition.OptionIsNotEmpty === condition) {
return data !== '';
}
const selectedOptionIds = data.split(','); const selectedOptionIds = data.split(',');
const filterOptionIds = content.split(','); const filterOptionIds = content.split(',');

View File

@ -21,7 +21,6 @@ import { filterBy, parseFilter } from '@/application/database-yjs/filter';
import { groupByField } from '@/application/database-yjs/group'; import { groupByField } from '@/application/database-yjs/group';
import { sortBy } from '@/application/database-yjs/sort'; import { sortBy } from '@/application/database-yjs/sort';
import { useViewsIdSelector } from '@/application/folder-yjs'; import { useViewsIdSelector } from '@/application/folder-yjs';
import { useId } from '@/components/_shared/context-provider/IdProvider';
import { parseYDatabaseCellToCell } from '@/components/database/components/cell/cell.parse'; import { parseYDatabaseCellToCell } from '@/components/database/components/cell/cell.parse';
import { DateTimeCell } from '@/components/database/components/cell/cell.type'; import { DateTimeCell } from '@/components/database/components/cell/cell.type';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@ -44,10 +43,10 @@ export interface Row {
const defaultVisible = [FieldVisibility.AlwaysShown, FieldVisibility.HideWhenEmpty]; const defaultVisible = [FieldVisibility.AlwaysShown, FieldVisibility.HideWhenEmpty];
export function useDatabaseViewsSelector() { export function useDatabaseViewsSelector(iidIndex: string) {
const database = useDatabase(); const database = useDatabase();
const { objectId: currentViewId } = useId();
const { viewsId: visibleViewsId, views: folderViews } = useViewsIdSelector(); const { viewsId: visibleViewsId, views: folderViews } = useViewsIdSelector();
const views = database?.get(YjsDatabaseKey.views); const views = database?.get(YjsDatabaseKey.views);
const [viewIds, setViewIds] = useState<string[]>([]); const [viewIds, setViewIds] = useState<string[]>([]);
const childViews = useMemo(() => { const childViews = useMemo(() => {
@ -58,16 +57,33 @@ export function useDatabaseViewsSelector() {
if (!views) return; if (!views) return;
const observerEvent = () => { const observerEvent = () => {
setViewIds( const viewsObj = views.toJSON();
Array.from(views.keys()).filter((id) => {
const view = folderViews?.get(id);
return ( const viewsSorted = Object.entries(viewsObj).sort((a, b) => {
visibleViewsId.includes(id) && const [, viewA] = a;
(view?.get(YjsFolderKey.bid) === currentViewId || view?.get(YjsFolderKey.id) === currentViewId) const [, viewB] = b;
);
}) return Number(viewB.created_at) - Number(viewA.created_at);
); });
const viewsId = [];
for (const viewItem of viewsSorted) {
const [key] = viewItem;
const view = folderViews?.get(key);
console.log('view', view?.get(YjsFolderKey.bid), iidIndex);
if (
visibleViewsId.includes(key) &&
view &&
(view.get(YjsFolderKey.bid) === iidIndex || view.get(YjsFolderKey.id) === iidIndex)
) {
viewsId.push(key);
}
}
console.log('viewsId', viewsId);
setViewIds(viewsId);
}; };
observerEvent(); observerEvent();
@ -76,7 +92,7 @@ export function useDatabaseViewsSelector() {
return () => { return () => {
views.unobserve(observerEvent); views.unobserve(observerEvent);
}; };
}, [visibleViewsId, views, folderViews, currentViewId]); }, [visibleViewsId, views, folderViews, iidIndex]);
return { return {
childViews, childViews,

View File

@ -10,7 +10,7 @@ import {
import { FieldType, SortCondition } from '@/application/database-yjs/database.type'; import { FieldType, SortCondition } from '@/application/database-yjs/database.type';
import { parseChecklistData, parseSelectOptionCellData } from '@/application/database-yjs/fields'; import { parseChecklistData, parseSelectOptionCellData } from '@/application/database-yjs/fields';
import { Row } from '@/application/database-yjs/selector'; import { Row } from '@/application/database-yjs/selector';
import orderBy from 'lodash-es/orderBy'; import { orderBy } from 'lodash-es';
import * as Y from 'yjs'; import * as Y from 'yjs';
export function sortBy(rows: Row[], sorts: YDatabaseSorts, fields: YDatabaseFields, rowMetas: Y.Map<YDoc>) { export function sortBy(rows: Row[], sorts: YDatabaseSorts, fields: YDatabaseFields, rowMetas: Y.Map<YDoc>) {

View File

@ -1,8 +1,9 @@
export enum CoverType { export enum CoverType {
NormalColor = 'color', NormalColor = 'color',
GradientColor = 'gradient', GradientColor = 'gradient',
BuildInImage = 'none', BuildInImage = 'built_in',
CustomImage = 'custom', CustomImage = 'custom',
LocalImage = 'local', LocalImage = 'local',
UpsplashImage = 'unsplash', UpsplashImage = 'unsplash',
None = 'none',
} }

View File

@ -10,7 +10,9 @@ export function useViewsIdSelector() {
const meta = folder?.get(YjsFolderKey.meta); const meta = folder?.get(YjsFolderKey.meta);
useEffect(() => { useEffect(() => {
if (!views) return; if (!views) {
return;
}
const trashUid = trash ? Array.from(trash.keys())[0] : null; const trashUid = trash ? Array.from(trash.keys())[0] : null;
const userTrash = trashUid ? trash?.get(trashUid) : null; const userTrash = trashUid ? trash?.get(trashUid) : null;

View File

@ -1,9 +1,9 @@
import { CollabOrigin, CollabType, YDatabase, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type'; import { CollabType, YDatabase, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import { import {
batchCollabs, batchCollabs,
getCollabStorage, getCollabStorage,
getCollabStorageWithAPICall, getCollabStorageWithAPICall,
getUserWorkspace, getCurrentWorkspace,
} from '@/application/services/js-services/storage'; } from '@/application/services/js-services/storage';
import { DatabaseService } from '@/application/services/services.type'; import { DatabaseService } from '@/application/services/services.type';
import * as Y from 'yjs'; import * as Y from 'yjs';
@ -17,14 +17,43 @@ export class JSDatabaseService implements DatabaseService {
// //
} }
async getDatabase( currentWorkspace() {
workspaceId: string, return getCurrentWorkspace();
}
async getWorkspaceDatabases(): Promise<{ views: string[]; database_id: string }[]> {
const workspace = await this.currentWorkspace();
if (!workspace) {
throw new Error('Workspace database not found');
}
const workspaceDatabase = await getCollabStorageWithAPICall(
workspace.id,
workspace.workspaceDatabaseId,
CollabType.WorkspaceDatabase
);
return workspaceDatabase.getMap(YjsEditorKey.data_section).get(YjsEditorKey.workspace_database).toJSON() as {
views: string[];
database_id: string;
}[];
}
async openDatabase(
databaseId: string, databaseId: string,
rowIds?: string[] rowIds?: string[]
): Promise<{ ): Promise<{
databaseDoc: YDoc; databaseDoc: YDoc;
rows: Y.Map<YDoc>; rows: Y.Map<YDoc>;
}> { }> {
const workspace = await this.currentWorkspace();
if (!workspace) {
throw new Error('Workspace database not found');
}
const workspaceId = workspace.id;
const isLoaded = this.loadedDatabaseId.has(databaseId); const isLoaded = this.loadedDatabaseId.has(databaseId);
const rootRowsDoc = const rootRowsDoc =
@ -106,68 +135,6 @@ export class JSDatabaseService implements DatabaseService {
}; };
} }
async openDatabase(
workspaceId: string,
viewId: string,
rowIds?: string[]
): Promise<{
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
}> {
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, rowIds);
const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => {
if (origin === CollabOrigin.LocalSync) {
// Send the update to the server
console.log('update', update);
}
};
databaseDoc.on('update', handleUpdate);
console.log('Database loaded', rows.toJSON());
return {
databaseDoc,
rows,
};
}
async loadDatabaseRows(workspaceId: string, rowIds: string[], rowCallback: (rowId: string, rowDoc: YDoc) => void) { async loadDatabaseRows(workspaceId: string, rowIds: string[], rowCallback: (rowId: string, rowDoc: YDoc) => void) {
try { try {
await batchCollabs( await batchCollabs(

View File

@ -1,5 +1,5 @@
import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type'; import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type';
import { getCollabStorageWithAPICall } from '@/application/services/js-services/storage'; import { getCollabStorageWithAPICall, getCurrentWorkspace } from '@/application/services/js-services/storage';
import { DocumentService } from '@/application/services/services.type'; import { DocumentService } from '@/application/services/services.type';
export class JSDocumentService implements DocumentService { export class JSDocumentService implements DocumentService {
@ -7,8 +7,14 @@ export class JSDocumentService implements DocumentService {
// //
} }
async openDocument(workspaceId: string, docId: string): Promise<YDoc> { async openDocument(docId: string): Promise<YDoc> {
const doc = await getCollabStorageWithAPICall(workspaceId, docId, CollabType.Document); const workspace = await getCurrentWorkspace();
if (!workspace) {
throw new Error('Workspace database not found');
}
const doc = await getCollabStorageWithAPICall(workspace.id, docId, CollabType.Document);
const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => { const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => {
if (origin === CollabOrigin.LocalSync) { if (origin === CollabOrigin.LocalSync) {

View File

@ -110,7 +110,7 @@ export async function batchCollabs(
const { doc } = await getCollabStorage(id, type); const { doc } = await getCollabStorage(id, type);
applyYDoc(doc, data); applyYDoc(doc, new Uint8Array(data));
rowCallback?.(id, doc); rowCallback?.(id, doc);
} }

View File

@ -1,4 +1,4 @@
import { UserProfile, UserWorkspace } from '@/application/user.type'; import { UserProfile, UserWorkspace, Workspace } from '@/application/user.type';
const userKey = 'user'; const userKey = 'user';
const workspaceKey = 'workspace'; const workspaceKey = 'workspace';
@ -34,3 +34,10 @@ export async function setUserWorkspace(workspace: UserWorkspace) {
localStorage.setItem(workspaceKey, str); localStorage.setItem(workspaceKey, str);
} }
export async function getCurrentWorkspace(): Promise<Workspace | undefined> {
const userProfile = await getSignInUser();
const userWorkspace = await getUserWorkspace();
return userWorkspace?.workspaces.find((workspace) => workspace.id === userProfile?.workspaceId);
}

View File

@ -93,10 +93,10 @@ export async function batchGetCollab(
})) }))
)) as unknown as Map<string, { doc_state: number[] }>; )) as unknown as Map<string, { doc_state: number[] }>;
const result: Record<string, Uint8Array> = {}; const result: Record<string, number[]> = {};
res.forEach((value, key) => { res.forEach((value, key) => {
result[key] = new Uint8Array(value.doc_state); result[key] = value.doc_state;
}); });
return result; return result;
} }

View File

@ -31,20 +31,12 @@ export interface AuthService {
} }
export interface DocumentService { export interface DocumentService {
openDocument: (workspaceId: string, docId: string) => Promise<YDoc>; openDocument: (docId: string) => Promise<YDoc>;
} }
export interface DatabaseService { export interface DatabaseService {
getWorkspaceDatabases: () => Promise<{ views: string[]; database_id: string }[]>;
openDatabase: ( openDatabase: (
workspaceId: string,
viewId: string,
rowIds?: string[]
) => Promise<{
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
}>;
getDatabase: (
workspaceId: string,
databaseId: string, databaseId: string,
rowIds?: string[] rowIds?: string[]
) => Promise<{ ) => Promise<{

View File

@ -7,24 +7,15 @@ export class TauriDatabaseService implements DatabaseService {
// //
} }
async getWorkspaceDatabases(): Promise<{ views: string[]; database_id: string }[]> {
return Promise.reject('Not implemented');
}
async closeDatabase(_databaseId: string) { async closeDatabase(_databaseId: string) {
return Promise.reject('Not implemented'); return Promise.reject('Not implemented');
} }
async openDatabase( async openDatabase(_viewId: string): Promise<{
_workspaceId: string,
_viewId: string
): Promise<{
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
}> {
return Promise.reject('Not implemented');
}
async getDatabase(
_workspaceId: string,
_databaseId: string
): Promise<{
databaseDoc: YDoc; databaseDoc: YDoc;
rows: Y.Map<YDoc>; rows: Y.Map<YDoc>;
}> { }> {

View File

@ -1,6 +1,5 @@
import { CollabOrigin, YjsEditorKey, YSharedRoot } from '@/application/collab.type'; import { CollabOrigin, YjsEditorKey, YSharedRoot } from '@/application/collab.type';
import { applySlateOp } from '@/application/slate-yjs/utils/applySlateOpts'; import { applyToYjs } from '@/application/slate-yjs/utils/applyToYjs';
import { translateYjsEvent } from 'src/application/slate-yjs/utils/translateYjsEvent';
import { Editor, Operation, Descendant } from 'slate'; import { Editor, Operation, Descendant } from 'slate';
import Y, { YEvent, Transaction } from 'yjs'; import Y, { YEvent, Transaction } from 'yjs';
import { yDocToSlateContent } from '@/application/slate-yjs/utils/convert'; import { yDocToSlateContent } from '@/application/slate-yjs/utils/convert';
@ -57,12 +56,11 @@ export const YjsEditor = {
export function withYjs<T extends Editor>( export function withYjs<T extends Editor>(
editor: T, editor: T,
doc: Y.Doc, doc: Y.Doc,
{ opts?: {
localOrigin,
}: {
localOrigin: CollabOrigin; localOrigin: CollabOrigin;
} }
): T & YjsEditor { ): T & YjsEditor {
const { localOrigin = CollabOrigin.Local } = opts ?? {};
const e = editor as T & YjsEditor; const e = editor as T & YjsEditor;
const { apply, onChange } = e; const { apply, onChange } = e;
@ -76,23 +74,34 @@ export function withYjs<T extends Editor>(
} }
e.children = content.children; e.children = content.children;
console.log('initializeDocumentContent', doc.getMap(YjsEditorKey.data_section).toJSON(), e.children);
Editor.normalize(editor, { force: true }); Editor.normalize(editor, { force: true });
}; };
e.applyRemoteEvents = (events: Array<YEvent<YSharedRoot>>, _: Transaction) => { const applyIntercept = (op: Operation) => {
YjsEditor.flushLocalChanges(e); if (YjsEditor.connected(e)) {
YjsEditor.storeLocalChange(e, op);
}
// TODO: handle remote events apply(op);
// This is a temporary implementation to apply remote events to slate };
const applyRemoteIntercept = (op: Operation) => {
apply(op);
};
e.applyRemoteEvents = (_events: Array<YEvent<YSharedRoot>>, _: Transaction) => {
// Flush local changes to ensure all local changes are applied before processing remote events
YjsEditor.flushLocalChanges(e);
// Replace the apply function to avoid storing remote changes as local changes
e.apply = applyRemoteIntercept;
// Initialize or update the document content to ensure it is in the correct state before applying remote events
initializeDocumentContent(); initializeDocumentContent();
Editor.withoutNormalizing(editor, () => {
events.forEach((event) => { // Restore the apply function to store local changes after applying remote changes
translateYjsEvent(e.sharedRoot, editor, event).forEach((op) => { e.apply = applyIntercept;
// apply remote events to slate, don't call e.apply here because e.apply has been overridden.
apply(op);
});
});
});
}; };
const handleYEvents = (events: Array<YEvent<YSharedRoot>>, transaction: Transaction) => { const handleYEvents = (events: Array<YEvent<YSharedRoot>>, transaction: Transaction) => {
@ -133,18 +142,12 @@ export function withYjs<T extends Editor>(
// parse changes and apply to ydoc // parse changes and apply to ydoc
doc.transact(() => { doc.transact(() => {
changes.forEach((change) => { changes.forEach((change) => {
applySlateOp(doc, { children: change.slateContent }, change.op); applyToYjs(doc, { children: change.slateContent }, change.op);
}); });
}, localOrigin); }, localOrigin);
}; };
e.apply = (op) => { e.apply = applyIntercept;
if (YjsEditor.connected(e)) {
YjsEditor.storeLocalChange(e, op);
}
apply(op);
};
e.onChange = () => { e.onChange = () => {
if (YjsEditor.connected(e)) { if (YjsEditor.connected(e)) {

View File

@ -0,0 +1,67 @@
import {
CollabOrigin,
YBlocks,
YChildrenMap,
YjsEditorKey,
YMeta,
YSharedRoot,
YTextMap,
} from '@/application/collab.type';
import { yDocToSlateContent } from '@/application/slate-yjs/utils/convert';
import { generateId, withTestingYDoc, withTestingYjsEditor } from './withTestingYjsEditor';
import { createEditor } from 'slate';
import { expect } from '@jest/globals';
import * as Y from 'yjs';
export async function runApplyRemoteEventsTest() {
const pageId = generateId();
const remoteDoc = withTestingYDoc(pageId);
const remote = withTestingYjsEditor(createEditor(), remoteDoc);
const localDoc = new Y.Doc();
Y.applyUpdateV2(localDoc, Y.encodeStateAsUpdateV2(remoteDoc));
const editor = withTestingYjsEditor(createEditor(), localDoc);
editor.connect();
expect(editor.children).toEqual(remote.children);
// update remote doc
insertBlock(remoteDoc, generateId(), pageId, 0);
remote.children = yDocToSlateContent(remoteDoc)?.children ?? [];
// apply remote changes to local doc
Y.transact(
localDoc,
() => {
Y.applyUpdateV2(localDoc, Y.encodeStateAsUpdateV2(remoteDoc));
},
CollabOrigin.Remote
);
expect(editor.children).toEqual(remote.children);
}
function insertBlock(doc: Y.Doc, blockId: string, parentId: string, index: number) {
const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
const document = sharedRoot.get(YjsEditorKey.document);
const blocks = document.get(YjsEditorKey.blocks) as YBlocks;
const meta = document.get(YjsEditorKey.meta) as YMeta;
const childrenMap = meta.get(YjsEditorKey.children_map) as YChildrenMap;
const textMap = meta.get(YjsEditorKey.text_map) as YTextMap;
const block = new Y.Map();
block.set(YjsEditorKey.block_id, blockId);
block.set(YjsEditorKey.block_children, blockId);
block.set(YjsEditorKey.block_type, 'paragraph');
block.set(YjsEditorKey.block_data, '{}');
block.set(YjsEditorKey.block_external_id, blockId);
blocks.set(blockId, block);
childrenMap.set(blockId, new Y.Array());
childrenMap.get(parentId).insert(index, [blockId]);
const text = new Y.Text();
text.insert(0, 'Hello, World!');
textMap.set(blockId, text);
}

View File

@ -0,0 +1,41 @@
import { withTestingYDoc, withTestingYjsEditor } from './withTestingYjsEditor';
import { yDocToSlateContent } from '../convert';
import { createEditor, Editor } from 'slate';
import { expect } from '@jest/globals';
import * as Y from 'yjs';
function normalizedSlateDoc(doc: Y.Doc) {
const editor = createEditor();
const yjsEditor = withTestingYjsEditor(editor, doc);
editor.children = yDocToSlateContent(doc)?.children ?? [];
return yjsEditor.children;
}
export async function runCollaborationTest() {
const doc = withTestingYDoc('1');
const editor = createEditor();
const yjsEditor = withTestingYjsEditor(editor, doc);
// Keep the 'local' editor state before applying run.
const baseState = Y.encodeStateAsUpdateV2(doc);
Editor.normalize(editor, { force: true });
expect(normalizedSlateDoc(doc)).toEqual(yjsEditor.children);
// Setup remote editor with input base state
const remoteDoc = new Y.Doc();
Y.applyUpdateV2(remoteDoc, baseState);
const remote = withTestingYjsEditor(createEditor(), remoteDoc);
// Apply changes from 'run'
Y.applyUpdateV2(remoteDoc, Y.encodeStateAsUpdateV2(yjsEditor.sharedRoot.doc!));
// Verify remote and editor state are equal
expect(normalizedSlateDoc(remoteDoc)).toEqual(remote.children);
expect(yjsEditor.children).toEqual(remote.children);
expect(normalizedSlateDoc(doc)).toEqual(yjsEditor.children);
}

View File

@ -0,0 +1,12 @@
import { runCollaborationTest } from './convert';
import { runApplyRemoteEventsTest } from './applyRemoteEvents';
describe('slate-yjs adapter', () => {
it('should pass the collaboration test', async () => {
await runCollaborationTest();
});
it('should pass the apply remote events test', async () => {
await runApplyRemoteEventsTest();
});
});

View File

@ -0,0 +1,45 @@
import { CollabOrigin, YjsEditorKey, YSharedRoot } from '@/application/collab.type';
import { withYjs } from '@/application/slate-yjs';
import { Editor } from 'slate';
import * as Y from 'yjs';
import { v4 as uuidv4 } from 'uuid';
export function generateId() {
return uuidv4();
}
export function withTestingYjsEditor(editor: Editor, doc: Y.Doc) {
const yjdEditor = withYjs(editor, doc, {
localOrigin: CollabOrigin.LocalSync,
});
return yjdEditor;
}
export function withTestingYDoc(docId: string) {
const doc = new Y.Doc();
const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
const document = new Y.Map();
const blocks = new Y.Map();
const meta = new Y.Map();
const children_map = new Y.Map();
const text_map = new Y.Map();
const rootBlock = new Y.Map();
const blockOrders = new Y.Array();
const pageId = docId;
sharedRoot.set(YjsEditorKey.document, document);
document.set(YjsEditorKey.page_id, pageId);
document.set(YjsEditorKey.blocks, blocks);
document.set(YjsEditorKey.meta, meta);
meta.set(YjsEditorKey.children_map, children_map);
meta.set(YjsEditorKey.text_map, text_map);
children_map.set(pageId, blockOrders);
blocks.set(pageId, rootBlock);
rootBlock.set(YjsEditorKey.block_id, pageId);
rootBlock.set(YjsEditorKey.block_children, pageId);
rootBlock.set(YjsEditorKey.block_type, 'page');
rootBlock.set(YjsEditorKey.block_data, '{}');
rootBlock.set(YjsEditorKey.block_external_id, '');
return doc;
}

View File

@ -1,7 +0,0 @@
import { Operation, Node } from 'slate';
import * as Y from 'yjs';
// transform slate op to yjs op and apply it to ydoc
export function applySlateOp(_ydoc: Y.Doc, _slateRoot: Node, _op: Operation) {
// console.log('applySlateOp', op);
}

View File

@ -0,0 +1,8 @@
import { Operation, Node } from 'slate';
import * as Y from 'yjs';
// transform slate op to yjs op and apply it to ydoc
export function applyToYjs(_ydoc: Y.Doc, _slateRoot: Node, op: Operation) {
if (op.type === 'set_selection') return;
console.log('applySlateOp', op);
}

View File

@ -10,22 +10,14 @@ import {
BlockData, BlockData,
BlockType, BlockType,
} from '@/application/collab.type'; } from '@/application/collab.type';
import { BlockJson } from '@/application/slate-yjs/utils/types';
import { getFontFamily } from '@/utils/font'; import { getFontFamily } from '@/utils/font';
import { uniq } from 'lodash-es'; import { uniq } from 'lodash-es';
import { Element, Text } from 'slate'; import { Element, Text } from 'slate';
interface BlockJson {
id: string;
ty: string;
data?: string;
children?: string;
external_id?: string;
}
export function yDocToSlateContent(doc: YDoc): Element | undefined { export function yDocToSlateContent(doc: YDoc): Element | undefined {
const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot; const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
console.log(sharedRoot.toJSON());
const document = sharedRoot.get(YjsEditorKey.document); const document = sharedRoot.get(YjsEditorKey.document);
const pageId = document.get(YjsEditorKey.page_id) as string; const pageId = document.get(YjsEditorKey.page_id) as string;
const blocks = document.get(YjsEditorKey.blocks) as YBlocks; const blocks = document.get(YjsEditorKey.blocks) as YBlocks;
@ -129,6 +121,7 @@ export function blockToSlateNode(block: BlockJson): Element {
return { return {
blockId: block.id, blockId: block.id,
relationId: block.children,
data: blockData, data: blockData,
type: block.ty, type: block.ty,
children: [], children: [],

View File

@ -1,11 +0,0 @@
import { YSharedRoot } from '@/application/collab.type';
import * as Y from 'yjs';
import { Editor, Operation } from 'slate';
export function translateYArrayEvent(
_sharedRoot: YSharedRoot,
_editor: Editor,
_event: Y.YEvent<Y.Array<string>>
): Operation[] {
return [];
}

View File

@ -1,29 +0,0 @@
import { YSharedRoot } from '@/application/collab.type';
import { translateYArrayEvent } from '@/application/slate-yjs/utils/translateYjsEvent/arrayEvent';
import { translateYMapEvent } from '@/application/slate-yjs/utils/translateYjsEvent/mapEvent';
import { Editor, Operation } from 'slate';
import * as Y from 'yjs';
import { translateYTextEvent } from 'src/application/slate-yjs/utils/translateYjsEvent/textEvent';
/**
* Translate a yjs event into slate operations. The editor state has to match the
* yText state before the event occurred.
*
* @param sharedType
* @param op
*/
export function translateYjsEvent(sharedRoot: YSharedRoot, editor: Editor, event: Y.YEvent<YSharedRoot>): Operation[] {
if (event instanceof Y.YMapEvent) {
return translateYMapEvent(sharedRoot, editor, event);
}
if (event instanceof Y.YTextEvent) {
return translateYTextEvent(sharedRoot, editor, event);
}
if (event instanceof Y.YArrayEvent) {
return translateYArrayEvent(sharedRoot, editor, event);
}
throw new Error('Unexpected Y event type');
}

View File

@ -1,11 +0,0 @@
import { YSharedRoot } from '@/application/collab.type';
import * as Y from 'yjs';
import { Editor, Operation } from 'slate';
export function translateYMapEvent(
_sharedRoot: YSharedRoot,
_editor: Editor,
_event: Y.YEvent<Y.Map<unknown>>
): Operation[] {
return [];
}

View File

@ -1,7 +0,0 @@
import { YSharedRoot } from '@/application/collab.type';
import * as Y from 'yjs';
import { Editor, Operation } from 'slate';
export function translateYTextEvent(_sharedRoot: YSharedRoot, _editor: Editor, _event: Y.YEvent<Y.Text>): Operation[] {
return [];
}

View File

@ -0,0 +1,29 @@
import { Node as SlateNode } from 'slate';
export interface BlockJson {
id: string;
ty: string;
data?: string;
children?: string;
external_id?: string;
}
export interface Operation {
type: OperationType;
}
export enum OperationType {
InsertNode = 'insert_node',
InsertChildren = 'insert_children',
}
export interface InsertNodeOperation extends Operation {
type: OperationType.InsertNode;
node: SlateNode;
}
export interface InsertChildrenOperation extends Operation {
type: OperationType.InsertChildren;
blockId: string;
children: string[];
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 KiB

View File

@ -3,7 +3,6 @@ import { useContext, createContext } from 'react';
export const IdContext = createContext<IdProviderProps | null>(null); export const IdContext = createContext<IdProviderProps | null>(null);
interface IdProviderProps { interface IdProviderProps {
workspaceId: string;
objectId: string; objectId: string;
} }

View File

@ -1,8 +1,9 @@
import { getCurrentWorkspace } from '@/application/services/js-services/storage';
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material'; import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material';
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
export function RecordNotFound({ open, workspaceId, title }: { workspaceId: string; open: boolean; title?: string }) { export function RecordNotFound({ open, title }: { open: boolean; title?: string }) {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
@ -15,8 +16,11 @@ export function RecordNotFound({ open, workspaceId, title }: { workspaceId: stri
</DialogContent> </DialogContent>
<DialogActions className={'flex w-full items-center justify-center'}> <DialogActions className={'flex w-full items-center justify-center'}>
<Button <Button
onClick={() => { onClick={async () => {
navigate(`/view/${workspaceId}`); const workspace = await getCurrentWorkspace();
if (!workspace) return;
navigate(`/view/${workspace.id}`);
}} }}
> >
Go back Go back

View File

@ -0,0 +1,123 @@
import { YDoc, YFolder, YjsEditorKey } from '@/application/collab.type';
import { applyYDoc } from '@/application/ydoc/apply';
import { FolderProvider } from '@/components/_shared/context-provider/FolderProvider';
import { IdProvider } from '@/components/_shared/context-provider/IdProvider';
import withAppWrapper from '@/components/app/withAppWrapper';
import { useState } from 'react';
import { Database } from './Database';
import { DatabaseContextProvider } from './DatabaseContext';
import * as Y from 'yjs';
import '@/components/layout/layout.scss';
describe('<Database />', () => {
beforeEach(() => {
cy.viewport(1280, 720);
Object.defineProperty(window.navigator, 'language', { value: 'en-US' });
Object.defineProperty(window.navigator, 'languages', { value: ['en-US'] });
cy.mockDatabase();
});
it('renders with a database', () => {
cy.fixture('folder').then((folderJson) => {
const doc = new Y.Doc();
const state = new Uint8Array(folderJson.data.doc_state);
applyYDoc(doc, state);
const folder = doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.folder) as YFolder;
cy.fixture(`database/4c658817-20db-4f56-b7f9-0637a22dfeb6`).then((database) => {
cy.fixture(`database/rows/4c658817-20db-4f56-b7f9-0637a22dfeb6`).then((rows) => {
const doc = new Y.Doc();
const rootRowsDoc = new Y.Doc();
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
const databaseState = new Uint8Array(database.data.doc_state);
applyYDoc(doc, databaseState);
Object.keys(rows).forEach((key) => {
const data = rows[key];
const rowDoc = new Y.Doc();
applyYDoc(rowDoc, new Uint8Array(data));
rowsFolder.set(key, rowDoc);
});
const onNavigateToView = cy.stub();
const AppWrapper = withAppWrapper(() => {
return (
<div className={'flex h-screen w-screen flex-col py-4'}>
<TestDatabase
databaseDoc={doc}
rows={rowsFolder}
folder={folder}
iidIndex={'7d2148fc-cace-4452-9c5c-96e52e6bf8b5'}
initialViewId={'7d2148fc-cace-4452-9c5c-96e52e6bf8b5'}
onNavigateToView={onNavigateToView}
/>
</div>
);
});
cy.mount(<AppWrapper />);
cy.get('[data-testid^=view-tab-]').should('have.length', 4);
cy.get('.database-grid').should('exist');
cy.get('[data-testid=view-tab-e410747b-5f2f-45a0-b2f7-890ad3001355]').click();
cy.get('.database-board').should('exist');
cy.wrap(onNavigateToView).should('have.been.calledOnceWith', 'e410747b-5f2f-45a0-b2f7-890ad3001355');
cy.wait(800);
cy.get('[data-testid=view-tab-7d2148fc-cace-4452-9c5c-96e52e6bf8b5]').click();
cy.get('.database-grid').should('exist');
cy.wrap(onNavigateToView).should('have.been.calledWith', '7d2148fc-cace-4452-9c5c-96e52e6bf8b5');
cy.wait(800);
cy.get('[data-testid=view-tab-2143e95d-5dcb-4e0f-bb2c-50944e6e019f]').click();
cy.get('.database-calendar').should('exist');
cy.wrap(onNavigateToView).should('have.been.calledWith', '2143e95d-5dcb-4e0f-bb2c-50944e6e019f');
});
});
});
});
});
function TestDatabase({
databaseDoc,
rows,
folder,
iidIndex,
initialViewId,
onNavigateToView,
}: {
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
folder: YFolder;
iidIndex: string;
initialViewId: string;
onNavigateToView: (viewId: string) => void;
}) {
const [activeViewId, setActiveViewId] = useState<string>(initialViewId);
const handleNavigateToView = (viewId: string) => {
setActiveViewId(viewId);
onNavigateToView(viewId);
};
return (
<FolderProvider folder={folder}>
<IdProvider objectId={iidIndex}>
<DatabaseContextProvider
viewId={activeViewId || iidIndex}
databaseDoc={databaseDoc}
rowDocMap={rows}
readOnly={true}
>
<Database iidIndex={iidIndex} viewId={activeViewId} onNavigateToView={handleNavigateToView} />
</DatabaseContextProvider>
</IdProvider>
</FolderProvider>
);
}

View File

@ -0,0 +1,89 @@
import { YDoc, YjsEditorKey } from '@/application/collab.type';
import { DatabaseContextState } from '@/application/database-yjs';
import { AFConfigContext } from '@/components/app/AppConfig';
import { Log } from '@/utils/log';
import { useCallback, useContext, useEffect, useState } from 'react';
export function useGetDatabaseId(iidIndex: string) {
const [databaseId, setDatabaseId] = useState<string>();
const databaseService = useContext(AFConfigContext)?.service?.databaseService;
const loadDatabaseId = useCallback(async () => {
if (!databaseService) return;
const databases = await databaseService.getWorkspaceDatabases();
console.log('databses', databases);
const id = databases.find((item) => item.views.includes(iidIndex))?.database_id;
if (!id) return;
setDatabaseId(id);
}, [iidIndex, databaseService]);
useEffect(() => {
void loadDatabaseId();
}, [loadDatabaseId]);
return databaseId;
}
export function useGetDatabaseDispatch() {
const databaseService = useContext(AFConfigContext)?.service?.databaseService;
const onOpenDatabase = useCallback(
async ({ databaseId, rowIds }: { databaseId: string; rowIds?: string[] }) => {
if (!databaseService) return Promise.reject();
return databaseService.openDatabase(databaseId, rowIds);
},
[databaseService]
);
const onCloseDatabase = useCallback(
(databaseId: string) => {
if (!databaseService) return;
void databaseService.closeDatabase(databaseId);
},
[databaseService]
);
return {
onOpenDatabase,
onCloseDatabase,
};
}
export function useLoadDatabase({ databaseId, rowIds }: { databaseId?: string; rowIds?: string[] }) {
const [doc, setDoc] = useState<YDoc | null>(null);
const [rows, setRows] = useState<DatabaseContextState['rowDocMap'] | null>(null); // Map<rowId, YDoc
const [notFound, setNotFound] = useState<boolean>(false);
const { onOpenDatabase, onCloseDatabase } = useGetDatabaseDispatch();
const handleOpenDatabase = useCallback(
async (databaseId: string, rowIds?: string[]) => {
try {
setDoc(null);
const { databaseDoc, rows } = await onOpenDatabase({
databaseId,
rowIds,
});
console.log('databaseDoc', databaseDoc.getMap(YjsEditorKey.data_section).toJSON());
console.log('rows', rows);
setDoc(databaseDoc);
setRows(rows);
} catch (e) {
Log.error(e);
setNotFound(true);
}
},
[onOpenDatabase]
);
useEffect(() => {
if (!databaseId) return;
void handleOpenDatabase(databaseId, rowIds);
return () => {
onCloseDatabase(databaseId);
};
}, [handleOpenDatabase, databaseId, rowIds, onCloseDatabase]);
return { doc, rows, notFound };
}

View File

@ -1,103 +1,24 @@
import { YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import { DatabaseContextState } from '@/application/database-yjs';
import { useId } from '@/components/_shared/context-provider/IdProvider';
import RecordNotFound from '@/components/_shared/not-found/RecordNotFound';
import { AFConfigContext } from '@/components/app/AppConfig';
import DatabaseViews from '@/components/database/DatabaseViews'; import DatabaseViews from '@/components/database/DatabaseViews';
import { DatabaseContextProvider } from '@/components/database/DatabaseContext';
import { Log } from '@/utils/log';
import CircularProgress from '@mui/material/CircularProgress';
import React, { memo, useCallback, useContext, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
export const Database = memo((props?: { onNavigateToRow?: (viewId: string, rowId: string) => void }) => { import React, { memo } from 'react';
const { objectId, workspaceId } = useId() || {};
const [search, setSearch] = useSearchParams();
const viewId = search.get('v'); export const Database = memo(
const [doc, setDoc] = useState<YDoc | null>(null); ({
const [rows, setRows] = useState<DatabaseContextState['rowDocMap'] | null>(null); // Map<rowId, YDoc viewId,
const [notFound, setNotFound] = useState<boolean>(false); onNavigateToView,
const databaseService = useContext(AFConfigContext)?.service?.databaseService; iidIndex,
}: {
const handleOpenDatabase = useCallback(async () => { iidIndex: string;
if (!databaseService || !workspaceId || !objectId) return; viewId: string;
onNavigateToView: (viewId: string) => void;
try { }) => {
setDoc(null); console.log('Database', viewId, iidIndex);
const { databaseDoc, rows } = await databaseService.openDatabase(workspaceId, objectId);
console.log('databaseDoc', databaseDoc.getMap(YjsEditorKey.data_section).toJSON());
console.log('rows', rows);
setDoc(databaseDoc);
setRows(rows);
} catch (e) {
Log.error(e);
setNotFound(true);
}
}, [databaseService, workspaceId, objectId]);
useEffect(() => {
setNotFound(false);
void handleOpenDatabase();
}, [handleOpenDatabase]);
const handleChangeView = useCallback(
(viewId: string) => {
setSearch({ v: viewId });
},
[setSearch]
);
const navigateToRow = useCallback(
(rowId: string) => {
const currentViewId = objectId || viewId;
if (props?.onNavigateToRow && currentViewId) {
props.onNavigateToRow(currentViewId, rowId);
return;
}
setSearch({ r: rowId });
},
[props, setSearch, viewId, objectId]
);
const databaseId = doc?.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database)?.get(YjsDatabaseKey.id) as string;
useEffect(() => {
if (!databaseId || !databaseService) return;
return () => {
void databaseService.closeDatabase(databaseId);
};
}, [databaseService, databaseId]);
if (notFound || !objectId) {
return <RecordNotFound open={notFound} workspaceId={workspaceId} />;
}
if (!rows || !doc) {
return ( return (
<div className={'flex h-full w-full items-center justify-center'}> <div className='appflowy-database relative flex w-full flex-1 select-text flex-col overflow-y-hidden'>
<CircularProgress /> <DatabaseViews iidIndex={iidIndex} onChangeView={onNavigateToView} viewId={viewId} />
</div> </div>
); );
} }
);
return (
<div className='appflowy-database relative flex w-full flex-1 select-text flex-col overflow-y-hidden'>
<DatabaseContextProvider
navigateToRow={navigateToRow}
viewId={viewId || objectId}
databaseDoc={doc}
rowDocMap={rows}
readOnly={true}
>
<DatabaseViews onChangeView={handleChangeView} currentViewId={viewId || objectId} />
</DatabaseContextProvider>
</div>
);
});
export default Database; export default Database;

View File

@ -0,0 +1,97 @@
import { YDoc, YFolder, YjsEditorKey } from '@/application/collab.type';
import { applyYDoc } from '@/application/ydoc/apply';
import { FolderProvider } from '@/components/_shared/context-provider/FolderProvider';
import { IdProvider } from '@/components/_shared/context-provider/IdProvider';
import withAppWrapper from '@/components/app/withAppWrapper';
import { DatabaseRow } from './DatabaseRow';
import { DatabaseContextProvider } from './DatabaseContext';
import * as Y from 'yjs';
import '@/components/layout/layout.scss';
describe('<DatabaseRow />', () => {
beforeEach(() => {
cy.viewport(1280, 720);
Object.defineProperty(window.navigator, 'language', { value: 'en-US' });
Object.defineProperty(window.navigator, 'languages', { value: ['en-US'] });
cy.mockDatabase();
cy.mockDocument('f56bdf0f-90c8-53fb-97d9-ad5860d2b7a0');
});
it('renders with a row', () => {
cy.wait(1000);
cy.fixture('folder').then((folderJson) => {
const doc = new Y.Doc();
const state = new Uint8Array(folderJson.data.doc_state);
applyYDoc(doc, state);
const folder = doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.folder) as YFolder;
cy.fixture('database/4c658817-20db-4f56-b7f9-0637a22dfeb6').then((database) => {
const doc = new Y.Doc();
const databaseState = new Uint8Array(database.data.doc_state);
applyYDoc(doc, databaseState);
cy.fixture('database/rows/4c658817-20db-4f56-b7f9-0637a22dfeb6').then((rows) => {
const rootRowsDoc = new Y.Doc();
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
const data = rows['2f944220-9f45-40d9-96b5-e8c0888daf7c'];
const rowDoc = new Y.Doc();
applyYDoc(rowDoc, new Uint8Array(data));
rowsFolder.set('2f944220-9f45-40d9-96b5-e8c0888daf7c', rowDoc);
const AppWrapper = withAppWrapper(() => {
return (
<div className={'flex h-screen w-screen flex-col overflow-y-auto py-4'}>
<TestDatabaseRow
rowId={'2f944220-9f45-40d9-96b5-e8c0888daf7c'}
databaseDoc={doc}
rows={rowsFolder}
folder={folder}
viewId={'7d2148fc-cace-4452-9c5c-96e52e6bf8b5'}
/>
</div>
);
});
cy.mount(<AppWrapper />);
cy.wait(1000);
cy.get('[role="textbox"]').should('exist');
});
});
});
});
});
function TestDatabaseRow({
rowId,
databaseDoc,
rows,
folder,
viewId,
}: {
rowId: string;
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
folder: YFolder;
viewId: string;
}) {
return (
<FolderProvider folder={folder}>
<IdProvider objectId={viewId}>
<DatabaseContextProvider
viewId={viewId}
readOnly={true}
isDatabaseRowPage
databaseDoc={databaseDoc}
rowDocMap={rows}
>
<DatabaseRow rowId={rowId} />
</DatabaseContextProvider>
</IdProvider>
</FolderProvider>
);
}

View File

@ -1,87 +1,25 @@
import { YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import { DatabaseContextState } from '@/application/database-yjs';
import { useId } from '@/components/_shared/context-provider/IdProvider';
import ComponentLoading from '@/components/_shared/progress/ComponentLoading'; import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
import { AFConfigContext } from '@/components/app/AppConfig';
import { DatabaseRowProperties, DatabaseRowSubDocument } from '@/components/database/components/database-row'; import { DatabaseRowProperties, DatabaseRowSubDocument } from '@/components/database/components/database-row';
import DatabaseRowHeader from '@/components/database/components/header/DatabaseRowHeader'; import DatabaseRowHeader from '@/components/database/components/header/DatabaseRowHeader';
import { DatabaseContextProvider } from '@/components/database/DatabaseContext';
import { Log } from '@/utils/log';
import { Divider } from '@mui/material'; import { Divider } from '@mui/material';
import CircularProgress from '@mui/material/CircularProgress'; import React, { Suspense } from 'react';
import React, { Suspense, useCallback, useContext, useEffect, useState } from 'react';
import RecordNotFound from 'src/components/_shared/not-found/RecordNotFound';
function DatabaseRow({ rowId }: { rowId: string }) {
const { objectId, workspaceId } = useId() || {};
const [doc, setDoc] = useState<YDoc | null>(null);
const [rows, setRows] = useState<DatabaseContextState['rowDocMap'] | null>(null); // Map<rowId, YDoc
const databaseService = useContext(AFConfigContext)?.service?.databaseService;
const [notFound, setNotFound] = useState<boolean>(false);
const handleOpenDatabaseRow = useCallback(async () => {
if (!databaseService || !workspaceId || !objectId) return;
try {
setDoc(null);
const { databaseDoc, rows } = await databaseService.openDatabase(workspaceId, objectId, [rowId]);
setDoc(databaseDoc);
setRows(rows);
} catch (e) {
Log.error(e);
setNotFound(true);
}
}, [databaseService, workspaceId, objectId, rowId]);
const databaseId = doc?.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database)?.get(YjsDatabaseKey.id) as string;
useEffect(() => {
setNotFound(false);
void handleOpenDatabaseRow();
}, [handleOpenDatabaseRow]);
useEffect(() => {
if (!databaseId || !databaseService) return;
return () => {
void databaseService.closeDatabase(databaseId);
};
}, [databaseService, databaseId]);
if (notFound || !objectId) {
return <RecordNotFound open={notFound} workspaceId={workspaceId} />;
}
if (!rows || !doc) {
return (
<div className={'flex h-full w-full items-center justify-center'}>
<CircularProgress />
</div>
);
}
export function DatabaseRow({ rowId }: { rowId: string }) {
return ( return (
<div className={'flex w-full justify-center'}> <div className={'flex w-full justify-center'}>
<div className={'max-w-screen w-[964px] min-w-0'}> <div className={'max-w-screen w-[964px] min-w-0'}>
<div className={' relative flex flex-col gap-4'}> <div className={' relative flex flex-col gap-4'}>
<DatabaseContextProvider <DatabaseRowHeader rowId={rowId} />
isDatabaseRowPage={true}
viewId={objectId}
databaseDoc={doc}
rowDocMap={rows}
readOnly={true}
>
<DatabaseRowHeader rowId={rowId} />
<div className={'flex flex-1 flex-col gap-4'}> <div className={'flex flex-1 flex-col gap-4'}>
<Suspense> <Suspense>
<DatabaseRowProperties rowId={rowId} /> <DatabaseRowProperties rowId={rowId} />
</Suspense> </Suspense>
<Divider className={'mx-16 max-md:mx-4'} /> <Divider className={'mx-16 max-md:mx-4'} />
<Suspense fallback={<ComponentLoading />}> <Suspense fallback={<ComponentLoading />}>
<DatabaseRowSubDocument rowId={rowId} /> <DatabaseRowSubDocument rowId={rowId} />
</Suspense> </Suspense>
</div> </div>
</DatabaseContextProvider>
</div> </div>
</div> </div>
</div> </div>

View File

@ -13,19 +13,21 @@ import DatabaseConditions from 'src/components/database/components/conditions/Da
function DatabaseViews({ function DatabaseViews({
onChangeView, onChangeView,
currentViewId, viewId,
iidIndex,
}: { }: {
onChangeView: (viewId: string) => void; onChangeView: (viewId: string) => void;
currentViewId: string; viewId: string;
iidIndex: string;
}) { }) {
const { childViews, viewIds } = useDatabaseViewsSelector(); const { childViews, viewIds } = useDatabaseViewsSelector(iidIndex);
const value = useMemo(() => { const value = useMemo(() => {
return Math.max( return Math.max(
0, 0,
viewIds.findIndex((id) => id === currentViewId) viewIds.findIndex((id) => id === viewId)
); );
}, [currentViewId, viewIds]); }, [viewId, viewIds]);
const [conditionsExpanded, setConditionsExpanded] = useState<boolean>(false); const [conditionsExpanded, setConditionsExpanded] = useState<boolean>(false);
const toggleExpanded = useCallback(() => { const toggleExpanded = useCallback(() => {
@ -58,7 +60,7 @@ function DatabaseViews({
toggleExpanded, toggleExpanded,
}} }}
> >
<DatabaseTabs selectedViewId={currentViewId} setSelectedViewId={onChangeView} viewIds={viewIds} /> <DatabaseTabs selectedViewId={viewId} setSelectedViewId={onChangeView} viewIds={viewIds} />
<DatabaseConditions /> <DatabaseConditions />
</DatabaseConditionsContext.Provider> </DatabaseConditionsContext.Provider>
<div className={'flex h-full w-full flex-1 flex-col overflow-hidden'}> <div className={'flex h-full w-full flex-1 flex-col overflow-hidden'}>

View File

@ -16,7 +16,7 @@ export function Board() {
} }
return ( return (
<div className={'grid-board flex w-full flex-1 flex-col'}> <div className={'database-board flex w-full flex-1 flex-col'}>
{groups.map((groupId) => ( {groups.map((groupId) => (
<Group key={groupId} groupId={groupId} /> <Group key={groupId} groupId={groupId} />
))} ))}

View File

@ -8,7 +8,7 @@ export function Calendar() {
const { dayPropGetter, localizer, formats, events, emptyEvents } = useCalendarSetup(); const { dayPropGetter, localizer, formats, events, emptyEvents } = useCalendarSetup();
return ( return (
<div className={'appflowy-calendar h-full max-h-[960px] px-16 pt-4 max-md:px-4'}> <div className={'database-calendar h-full max-h-[960px] px-16 pt-4 max-md:px-4'}>
<BigCalendar <BigCalendar
components={{ components={{
toolbar: (props) => <Toolbar {...props} emptyEvents={emptyEvents} />, toolbar: (props) => <Toolbar {...props} emptyEvents={emptyEvents} />,

View File

@ -1,3 +1,5 @@
@use "src/styles/mixin.scss";
$today-highlight-bg: transparent; $today-highlight-bg: transparent;
@import 'react-big-calendar/lib/sass/styles'; @import 'react-big-calendar/lib/sass/styles';
@import 'react-big-calendar/lib/addons/dragAndDrop/styles'; // if using DnD @import 'react-big-calendar/lib/addons/dragAndDrop/styles'; // if using DnD
@ -34,20 +36,7 @@ $today-highlight-bg: transparent;
} }
&::-webkit-scrollbar { @include mixin.scrollbar-style;
width: 4px;
height: 4px;
}
&:hover {
&::-webkit-scrollbar-thumb, & *::-webkit-scrollbar-thumb {
border-radius: 4px;
background-color: var(--scrollbar-thumb);
}
}
} }

View File

@ -1,8 +1,6 @@
import { useFieldsSelector, useNavigateToRow } from '@/application/database-yjs'; import { useFieldsSelector, useNavigateToRow } from '@/application/database-yjs';
import OpenAction from '@/components/database/components/database-row/OpenAction';
import CardField from '@/components/database/components/field/CardField'; import CardField from '@/components/database/components/field/CardField';
import { getPlatform } from '@/utils/platform'; import React, { memo, useEffect, useMemo } from 'react';
import React, { useEffect, useMemo } from 'react';
export interface CardProps { export interface CardProps {
groupFieldId: string; groupFieldId: string;
@ -11,11 +9,10 @@ export interface CardProps {
isDragging?: boolean; isDragging?: boolean;
} }
export function Card({ groupFieldId, rowId, onResize, isDragging }: CardProps) { export const Card = memo(({ groupFieldId, rowId, onResize, isDragging }: CardProps) => {
const fields = useFieldsSelector(); const fields = useFieldsSelector();
const showFields = useMemo(() => fields.filter((field) => field.fieldId !== groupFieldId), [fields, groupFieldId]); const showFields = useMemo(() => fields.filter((field) => field.fieldId !== groupFieldId), [fields, groupFieldId]);
const [isHovering, setIsHovering] = React.useState(false);
const ref = React.useRef<HTMLDivElement | null>(null); const ref = React.useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
@ -35,35 +32,24 @@ export function Card({ groupFieldId, rowId, onResize, isDragging }: CardProps) {
}; };
}, [onResize, isDragging]); }, [onResize, isDragging]);
const isMobile = useMemo(() => {
return getPlatform().isMobile;
}, []);
const navigateToRow = useNavigateToRow(); const navigateToRow = useNavigateToRow();
return ( return (
<div <div
onClick={() => { onClick={() => {
if (isMobile) { navigateToRow?.(rowId);
navigateToRow?.(rowId);
}
}} }}
ref={ref} ref={ref}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
style={{ style={{
minHeight: '38px', minHeight: '38px',
}} }}
className='relative flex cursor-pointer flex-col rounded-lg border border-line-divider p-3 text-xs shadow-sm hover:bg-fill-list-active hover:shadow' className='relative flex cursor-pointer flex-col rounded-lg border border-line-border p-3 text-xs shadow-sm hover:bg-fill-list-active hover:shadow'
> >
{showFields.map((field, index) => { {showFields.map((field, index) => {
return <CardField index={index} key={field.fieldId} rowId={rowId} fieldId={field.fieldId} />; return <CardField index={index} key={field.fieldId} rowId={rowId} fieldId={field.fieldId} />;
})} })}
<div className={`absolute top-1.5 right-1.5 ${isHovering ? 'block' : 'hidden'}`}>
<OpenAction rowId={rowId} />
</div>
</div> </div>
); );
} });
export default Card; export default Card;

View File

@ -4,7 +4,7 @@ import { Tag } from '@/components/_shared/tag';
import ListItem from '@/components/database/components/board/column/ListItem'; import ListItem from '@/components/database/components/board/column/ListItem';
import { useRenderColumn } from '@/components/database/components/board/column/useRenderColumn'; import { useRenderColumn } from '@/components/database/components/board/column/useRenderColumn';
import { useMeasureHeight } from '@/components/database/components/cell/useMeasure'; import { useMeasureHeight } from '@/components/database/components/cell/useMeasure';
import React, { useCallback, useEffect, useMemo } from 'react'; import React, { memo, useCallback, useEffect, useMemo } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import { VariableSizeList } from 'react-window'; import { VariableSizeList } from 'react-window';
@ -14,86 +14,89 @@ export interface ColumnProps {
fieldId: string; fieldId: string;
} }
export function Column({ id, rows, fieldId }: ColumnProps) { export const Column = memo(
const { header } = useRenderColumn(id, fieldId); ({ id, rows, fieldId }: ColumnProps) => {
const ref = React.useRef<VariableSizeList | null>(null); const { header } = useRenderColumn(id, fieldId);
const forceUpdate = useCallback((index: number) => { const ref = React.useRef<VariableSizeList | null>(null);
ref.current?.resetAfterIndex(index, true); const forceUpdate = useCallback((index: number) => {
}, []); ref.current?.resetAfterIndex(index, true);
}, []);
useEffect(() => { useEffect(() => {
forceUpdate(0); forceUpdate(0);
}, [rows, forceUpdate]); }, [rows, forceUpdate]);
const measureRows = useMemo( const measureRows = useMemo(
() => () =>
rows?.map((row) => { rows?.map((row) => {
return { return {
rowId: row.id, rowId: row.id,
};
}) || [],
[rows]
);
const { rowHeight, onResize } = useMeasureHeight({ forceUpdate, rows: measureRows });
const Row = useCallback(
({ index, style, data }: { index: number; style: React.CSSProperties; data: Row[] }) => {
const item = data[index];
// We are rendering an extra item for the placeholder
if (!item) {
return null;
}
const onResizeCallback = (height: number) => {
onResize(index, 0, {
width: 0,
height: height + 8,
});
}; };
}) || [],
[rows]
);
const { rowHeight, onResize } = useMeasureHeight({ forceUpdate, rows: measureRows });
const Row = useCallback( return <ListItem fieldId={fieldId} onResize={onResizeCallback} item={item} style={style} />;
({ index, style, data }: { index: number; style: React.CSSProperties; data: Row[] }) => { },
const item = data[index]; [fieldId, onResize]
);
// We are rendering an extra item for the placeholder const getItemSize = useCallback(
if (!item) { (index: number) => {
return null; if (!rows || index >= rows.length) return 0;
} const row = rows[index];
const onResizeCallback = (height: number) => { if (!row) return 0;
onResize(index, 0, { return rowHeight(index);
width: 0, },
height: height + 8, [rowHeight, rows]
}); );
}; const rowCount = rows?.length || 0;
return <ListItem fieldId={fieldId} onResize={onResizeCallback} item={item} style={style} />; return (
}, <div key={id} className='column flex w-[230px] flex-col gap-4'>
[fieldId, onResize] <div className='column-header flex h-[24px] items-center text-xs font-medium'>
); <Tag label={header?.name} color={header?.color} />
</div>
const getItemSize = useCallback( <div className={'w-full flex-1 overflow-hidden'}>
(index: number) => { <AutoSizer>
if (!rows || index >= rows.length) return 0; {({ height, width }: { height: number; width: number }) => {
const row = rows[index]; return (
<VariableSizeList
if (!row) return 0; ref={ref}
return rowHeight(index); height={height}
}, itemCount={rowCount}
[rowHeight, rows] itemSize={getItemSize}
); width={width}
const rowCount = rows?.length || 0; outerElementType={AFScroller}
itemData={rows}
return ( >
<div key={id} className='column flex w-[230px] flex-col gap-4'> {Row}
<div className='column-header flex h-[24px] items-center text-xs font-medium'> </VariableSizeList>
<Tag label={header?.name} color={header?.color} /> );
}}
</AutoSizer>
</div>
</div> </div>
);
<div className={'w-full flex-1 overflow-hidden'}> },
<AutoSizer> (prev, next) => JSON.stringify(prev) === JSON.stringify(next)
{({ height, width }: { height: number; width: number }) => { );
return (
<VariableSizeList
ref={ref}
height={height}
itemCount={rowCount}
itemSize={getItemSize}
width={width}
outerElementType={AFScroller}
itemData={rows}
>
{Row}
</VariableSizeList>
);
}}
</AutoSizer>
</div>
</div>
);
}

View File

@ -1,29 +1,33 @@
import { Row } from '@/application/database-yjs'; import { Row } from '@/application/database-yjs';
import React from 'react'; import React, { memo } from 'react';
import { areEqual } from 'react-window';
import Card from 'src/components/database/components/board/card/Card'; import Card from 'src/components/database/components/board/card/Card';
export const ListItem = ({ export const ListItem = memo(
item, ({
style, item,
onResize, style,
fieldId, onResize,
}: { fieldId,
item?: Row; }: {
style?: React.CSSProperties; item?: Row;
fieldId: string; style?: React.CSSProperties;
onResize?: (height: number) => void; fieldId: string;
}) => { onResize?: (height: number) => void;
return ( }) => {
<div return (
style={{ <div
...style, style={{
width: 'calc(100% - 2px)', ...style,
}} width: 'calc(100% - 2px)',
className={`w-full bg-bg-body`} }}
> className={`w-full bg-bg-body`}
{item?.id ? <Card onResize={onResize} rowId={item.id} groupFieldId={fieldId} /> : null} >
</div> {item?.id ? <Card onResize={onResize} rowId={item.id} groupFieldId={fieldId} /> : null}
); </div>
}; );
},
areEqual
);
export default ListItem; export default ListItem;

View File

@ -6,29 +6,27 @@ import {
useFieldSelector, useFieldSelector,
useNavigateToRow, useNavigateToRow,
} from '@/application/database-yjs'; } from '@/application/database-yjs';
import { useId } from '@/components/_shared/context-provider/IdProvider';
import { AFConfigContext } from '@/components/app/AppConfig';
import { RelationCell, RelationCellData } from '@/components/database/components/cell/cell.type'; import { RelationCell, RelationCellData } from '@/components/database/components/cell/cell.type';
import { RelationPrimaryValue } from '@/components/database/components/cell/relation/RelationPrimaryValue'; import { RelationPrimaryValue } from '@/components/database/components/cell/relation/RelationPrimaryValue';
import React, { useContext, useEffect, useMemo, useState } from 'react'; import { useGetDatabaseDispatch } from '@/components/database/Database.hooks';
import React, { useEffect, useMemo, useState } from 'react';
function RelationItems({ style, cell, fieldId }: { cell: RelationCell; fieldId: string; style?: React.CSSProperties }) { function RelationItems({ style, cell, fieldId }: { cell: RelationCell; fieldId: string; style?: React.CSSProperties }) {
const { field } = useFieldSelector(fieldId); const { field } = useFieldSelector(fieldId);
const currentDatabaseId = useDatabase()?.get(YjsDatabaseKey.id); const currentDatabaseId = useDatabase()?.get(YjsDatabaseKey.id);
const workspaceId = useId()?.workspaceId; const { onOpenDatabase, onCloseDatabase } = useGetDatabaseDispatch();
const rowIds = useMemo(() => { const rowIds = useMemo(() => {
return (cell.data?.toJSON() as RelationCellData) ?? []; return (cell.data?.toJSON() as RelationCellData) ?? [];
}, [cell.data]); }, [cell.data]);
const databaseId = rowIds.length > 0 && field ? parseRelationTypeOption(field).database_id : undefined; const databaseId = rowIds.length > 0 && field ? parseRelationTypeOption(field).database_id : undefined;
const databaseService = useContext(AFConfigContext)?.service?.databaseService;
const [databasePrimaryFieldId, setDatabasePrimaryFieldId] = useState<string | undefined>(undefined); const [databasePrimaryFieldId, setDatabasePrimaryFieldId] = useState<string | undefined>(undefined);
const [rows, setRows] = useState<DatabaseContextState['rowDocMap'] | null>(); const [rows, setRows] = useState<DatabaseContextState['rowDocMap'] | null>();
const navigateToRow = useNavigateToRow(); const navigateToRow = useNavigateToRow();
useEffect(() => { useEffect(() => {
if (!workspaceId || !databaseId || !rowIds.length) return; if (!databaseId || !rowIds.length) return;
void databaseService?.getDatabase(workspaceId, databaseId, rowIds).then(({ databaseDoc: doc, rows }) => { void onOpenDatabase({ databaseId, rowIds }).then(({ databaseDoc: doc, rows }) => {
const fields = doc const fields = doc
.getMap(YjsEditorKey.data_section) .getMap(YjsEditorKey.data_section)
.get(YjsEditorKey.database) .get(YjsEditorKey.database)
@ -42,15 +40,15 @@ function RelationItems({ style, cell, fieldId }: { cell: RelationCell; fieldId:
setRows(rows); setRows(rows);
}); });
}, [workspaceId, databaseId, databaseService, rowIds]); }, [onOpenDatabase, databaseId, rowIds, onCloseDatabase]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (currentDatabaseId !== databaseId && databaseId) { if (currentDatabaseId !== databaseId && databaseId) {
void databaseService?.closeDatabase(databaseId); onCloseDatabase(databaseId);
} }
}; };
}, [currentDatabaseId, databaseId, databaseService]); }, [databaseId, currentDatabaseId, onCloseDatabase]);
return ( return (
<div style={style} className={'relation-cell flex w-full items-center gap-2'}> <div style={style} className={'relation-cell flex w-full items-center gap-2'}>

View File

@ -1,27 +1,24 @@
import { YDoc } from '@/application/collab.type'; import { YDoc } from '@/application/collab.type';
import { useRowMetaSelector } from '@/application/database-yjs'; import { useRowMetaSelector } from '@/application/database-yjs';
import { useId } from '@/components/_shared/context-provider/IdProvider';
import { AFConfigContext } from '@/components/app/AppConfig'; import { AFConfigContext } from '@/components/app/AppConfig';
import { Editor } from '@/components/editor'; import { Editor } from '@/components/editor';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import React, { useCallback, useContext, useEffect, useState } from 'react'; import React, { useCallback, useContext, useEffect, useState } from 'react';
export function DatabaseRowSubDocument({ rowId }: { rowId: string }) { export function DatabaseRowSubDocument({ rowId }: { rowId: string }) {
const { workspaceId } = useId() || {};
const meta = useRowMetaSelector(rowId); const meta = useRowMetaSelector(rowId);
const documentId = meta?.documentId; const documentId = meta?.documentId;
console.log('documentId', documentId);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [doc, setDoc] = useState<YDoc | null>(null); const [doc, setDoc] = useState<YDoc | null>(null);
const documentService = useContext(AFConfigContext)?.service?.documentService; const documentService = useContext(AFConfigContext)?.service?.documentService;
const handleOpenDocument = useCallback(async () => { const handleOpenDocument = useCallback(async () => {
if (!documentService || !workspaceId || !documentId) return; if (!documentService || !documentId) return;
try { try {
setDoc(null); setDoc(null);
const doc = await documentService.openDocument(workspaceId, documentId); const doc = await documentService.openDocument(documentId);
console.log('doc', doc); console.log('doc', doc);
setDoc(doc); setDoc(doc);
@ -29,7 +26,7 @@ export function DatabaseRowSubDocument({ rowId }: { rowId: string }) {
console.error(e); console.error(e);
// haven't created by client, ignore error and show empty // haven't created by client, ignore error and show empty
} }
}, [documentService, workspaceId, documentId]); }, [documentService, documentId]);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef } from 'react'; import React, { memo, useEffect, useRef } from 'react';
import { GridChildComponentProps, VariableSizeGrid } from 'react-window'; import { areEqual, GridChildComponentProps, VariableSizeGrid } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import { GridColumnType, RenderColumn, GridColumn } from '../grid-column'; import { GridColumnType, RenderColumn, GridColumn } from '../grid-column';
@ -10,24 +10,25 @@ export interface GridHeaderProps {
scrollLeft?: number; scrollLeft?: number;
} }
const Cell = memo(({ columnIndex, style, data }: GridChildComponentProps) => {
const column = data[columnIndex];
// Placeholder for Action toolbar
if (!column || column.type === GridColumnType.Action) return <div style={style} />;
if (column.type === GridColumnType.Field) {
return (
<div style={style}>
<GridColumn column={column} index={columnIndex} />
</div>
);
}
return <div style={style} className={'border-t border-b border-line-divider'} />;
}, areEqual);
export const GridHeader = ({ scrollLeft, onScrollLeft, columnWidth, columns }: GridHeaderProps) => { export const GridHeader = ({ scrollLeft, onScrollLeft, columnWidth, columns }: GridHeaderProps) => {
const ref = useRef<VariableSizeGrid | null>(null); const ref = useRef<VariableSizeGrid | null>(null);
const Cell = useCallback(({ columnIndex, style, data }: GridChildComponentProps) => {
const column = data[columnIndex];
// Placeholder for Action toolbar
if (!column || column.type === GridColumnType.Action) return <div style={style} />;
if (column.type === GridColumnType.Field) {
return (
<div style={style}>
<GridColumn column={column} index={columnIndex} />
</div>
);
}
return <div style={style} className={'border-t border-b border-line-divider'} />;
}, []);
useEffect(() => { useEffect(() => {
if (ref.current) { if (ref.current) {

View File

@ -1,5 +1,6 @@
import { areEqual } from 'react-window';
import { GridColumnType } from '../grid-column'; import { GridColumnType } from '../grid-column';
import React from 'react'; import React, { memo } from 'react';
import GridCell from '../grid-cell/GridCell'; import GridCell from '../grid-cell/GridCell';
export interface GridRowCellProps { export interface GridRowCellProps {
@ -11,7 +12,7 @@ export interface GridRowCellProps {
onResize?: (rowIndex: number, columnIndex: number, size: { width: number; height: number }) => void; onResize?: (rowIndex: number, columnIndex: number, size: { width: number; height: number }) => void;
} }
export function GridRowCell({ onResize, rowIndex, columnIndex, rowId, fieldId, type }: GridRowCellProps) { export const GridRowCell = memo(({ onResize, rowIndex, columnIndex, rowId, fieldId, type }: GridRowCellProps) => {
if (type === GridColumnType.Field && fieldId) { if (type === GridColumnType.Field && fieldId) {
return ( return (
<GridCell rowIndex={rowIndex} onResize={onResize} rowId={rowId} fieldId={fieldId} columnIndex={columnIndex} /> <GridCell rowIndex={rowIndex} onResize={onResize} rowId={rowId} fieldId={fieldId} columnIndex={columnIndex} />
@ -23,6 +24,6 @@ export function GridRowCell({ onResize, rowIndex, columnIndex, rowId, fieldId, t
} }
return null; return null;
} }, areEqual);
export default GridRowCell; export default GridRowCell;

View File

@ -1,5 +1,5 @@
import { DEFAULT_ROW_HEIGHT } from '@/application/database-yjs/const';
import { AFScroller } from '@/components/_shared/scroller'; import { AFScroller } from '@/components/_shared/scroller';
import { useMeasureHeight } from '@/components/database/components/cell/useMeasure';
import { GridColumnType, RenderColumn } from '../grid-column'; import { GridColumnType, RenderColumn } from '../grid-column';
import { GridCalculateRowCell, GridRowCell, RenderRowType, useRenderRows } from '../grid-row'; import { GridCalculateRowCell, GridRowCell, RenderRowType, useRenderRows } from '../grid-row';
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
@ -18,7 +18,11 @@ export interface GridTableProps {
export const GridTable = ({ scrollLeft, columnWidth, columns, onScrollLeft }: GridTableProps) => { export const GridTable = ({ scrollLeft, columnWidth, columns, onScrollLeft }: GridTableProps) => {
const ref = useRef<VariableSizeGrid | null>(null); const ref = useRef<VariableSizeGrid | null>(null);
const { rows } = useRenderRows(); const { rows } = useRenderRows();
const rowHeights = useRef<{ [key: string]: number }>({}); const forceUpdate = useCallback((index: number) => {
ref.current?.resetAfterRowIndex(index, true);
}, []);
const { rowHeight, onResize } = useMeasureHeight({ forceUpdate, rows });
useEffect(() => { useEffect(() => {
if (ref.current) { if (ref.current) {
@ -32,40 +36,6 @@ export const GridTable = ({ scrollLeft, columnWidth, columns, onScrollLeft }: Gr
} }
}, [columns]); }, [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( const getItemKey = useCallback(
({ columnIndex, rowIndex }: { columnIndex: number; rowIndex: number }) => { ({ columnIndex, rowIndex }: { columnIndex: number; rowIndex: number }) => {
const row = rows[rowIndex]; const row = rows[rowIndex];

View File

@ -1,10 +1,9 @@
import { DatabaseViewLayout, ViewLayout, YjsDatabaseKey, YjsFolderKey, YView } from '@/application/collab.type'; import { DatabaseViewLayout, ViewLayout, YjsDatabaseKey, YjsFolderKey, YView } from '@/application/collab.type';
import { useDatabaseView } from '@/application/database-yjs'; import { useDatabaseView } from '@/application/database-yjs';
import { useFolderContext } from '@/application/folder-yjs'; import { useFolderContext } from '@/application/folder-yjs';
import { useId } from '@/components/_shared/context-provider/IdProvider';
import { DatabaseActions } from '@/components/database/components/conditions'; import { DatabaseActions } from '@/components/database/components/conditions';
import { Tooltip } from '@mui/material'; import { Tooltip } from '@mui/material';
import { forwardRef, FunctionComponent, SVGProps, useCallback, useEffect, useMemo } from 'react'; import { forwardRef, FunctionComponent, SVGProps, useCallback, useMemo } from 'react';
import { ViewTabs, ViewTab } from './ViewTabs'; import { ViewTabs, ViewTab } from './ViewTabs';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -30,7 +29,6 @@ const DatabaseIcons: {
export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>( export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
({ viewIds, selectedViewId, setSelectedViewId }, ref) => { ({ viewIds, selectedViewId, setSelectedViewId }, ref) => {
const objectId = useId().objectId;
const { t } = useTranslation(); const { t } = useTranslation();
const folder = useFolderContext(); const folder = useFolderContext();
const view = useDatabaseView(); const view = useDatabaseView();
@ -40,13 +38,6 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
setSelectedViewId?.(newValue); setSelectedViewId?.(newValue);
}; };
useEffect(() => {
if (selectedViewId === undefined) {
setSelectedViewId?.(objectId);
}
}, [selectedViewId, setSelectedViewId, objectId]);
const isSelected = useMemo(() => viewIds.some((viewId) => viewId === selectedViewId), [viewIds, selectedViewId]);
const getFolderView = useCallback( const getFolderView = useCallback(
(viewId: string) => { (viewId: string) => {
if (!folder) return null; if (!folder) return null;
@ -80,7 +71,7 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
scrollButtons={false} scrollButtons={false}
variant='scrollable' variant='scrollable'
allowScrollButtonsMobile allowScrollButtonsMobile
value={isSelected ? selectedViewId : objectId} value={selectedViewId}
onChange={handleChange} onChange={handleChange}
> >
{viewIds.map((viewId) => { {viewIds.map((viewId) => {
@ -94,11 +85,12 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
return ( return (
<ViewTab <ViewTab
key={viewId} key={viewId}
data-testid={`view-tab-${viewId}`}
icon={<Icon className={'h-4 w-4'} />} icon={<Icon className={'h-4 w-4'} />}
iconPosition='start' iconPosition='start'
color='inherit' color='inherit'
label={ label={
<Tooltip title={name} placement={'right'}> <Tooltip title={name} enterDelay={1000} enterNextDelay={1000} placement={'right'}>
<span className={'max-w-[120px] truncate'}>{name || t('grid.title.placeholder')}</span> <span className={'max-w-[120px] truncate'}>{name || t('grid.title.placeholder')}</span>
</Tooltip> </Tooltip>
} }

View File

@ -29,7 +29,7 @@ export function Grid() {
rowOrders, rowOrders,
}} }}
> >
<div className={'flex w-full flex-1 flex-col'}> <div className={'database-grid flex w-full flex-1 flex-col'}>
<GridHeader scrollLeft={scrollLeft} columnWidth={columnWidth} columns={fields} onScrollLeft={setScrollLeft} /> <GridHeader scrollLeft={scrollLeft} columnWidth={columnWidth} columns={fields} onScrollLeft={setScrollLeft} />
<div className={'grid-scroll-table w-full flex-1'}> <div className={'grid-scroll-table w-full flex-1'}>
<GridTable <GridTable

View File

@ -12,7 +12,7 @@ import React, { Suspense, useCallback, useContext, useEffect, useMemo, useState
import RecordNotFound from 'src/components/_shared/not-found/RecordNotFound'; import RecordNotFound from 'src/components/_shared/not-found/RecordNotFound';
export const Document = () => { export const Document = () => {
const { objectId: documentId, workspaceId } = useId() || {}; const { objectId: documentId } = useId() || {};
const [doc, setDoc] = useState<YDoc | null>(null); const [doc, setDoc] = useState<YDoc | null>(null);
const [notFound, setNotFound] = useState<boolean>(false); const [notFound, setNotFound] = useState<boolean>(false);
const extra = usePageInfo(documentId).extra; const extra = usePageInfo(documentId).extra;
@ -27,17 +27,17 @@ export const Document = () => {
const documentService = useContext(AFConfigContext)?.service?.documentService; const documentService = useContext(AFConfigContext)?.service?.documentService;
const handleOpenDocument = useCallback(async () => { const handleOpenDocument = useCallback(async () => {
if (!documentService || !workspaceId || !documentId) return; if (!documentService || !documentId) return;
try { try {
setDoc(null); setDoc(null);
const doc = await documentService.openDocument(workspaceId, documentId); const doc = await documentService.openDocument(documentId);
setDoc(doc); setDoc(doc);
} catch (e) { } catch (e) {
Log.error(e); Log.error(e);
setNotFound(true); setNotFound(true);
} }
}, [documentService, workspaceId, documentId]); }, [documentService, documentId]);
useEffect(() => { useEffect(() => {
setNotFound(false); setNotFound(false);
@ -105,7 +105,7 @@ export const Document = () => {
</div> </div>
)} )}
<RecordNotFound open={notFound} workspaceId={workspaceId} /> <RecordNotFound open={notFound} />
</> </>
); );
}; };

View File

@ -1,25 +1,21 @@
import { DocCoverType, YDoc } from '@/application/collab.type';
import { CoverType } from '@/application/folder-yjs/folder.type';
import { useId } from '@/components/_shared/context-provider/IdProvider';
import { usePageInfo } from '@/components/_shared/page/usePageInfo';
import { useBlockCover } from '@/components/document/document_header/useBlockCover';
import { showColorsForImage } from '@/components/document/document_header/utils'; import { showColorsForImage } from '@/components/document/document_header/utils';
import { renderColor } from '@/utils/color'; import { renderColor } from '@/utils/color';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import DefaultImage from './default_cover.jpg';
function DocumentCover({ doc, onTextColor }: { doc: YDoc; onTextColor: (color: string) => void }) {
const viewId = useId().objectId;
const { extra } = usePageInfo(viewId);
const pageCover = extra.cover;
const { cover } = useBlockCover(doc);
function DocumentCover({
coverValue,
coverType,
onTextColor,
}: {
coverValue?: string;
coverType?: string;
onTextColor: (color: string) => void;
}) {
const renderCoverColor = useCallback((color: string) => { const renderCoverColor = useCallback((color: string) => {
return ( return (
<div <div
style={{ style={{
backgroundColor: renderColor(color), background: renderColor(color),
}} }}
className={`h-full w-full`} className={`h-full w-full`}
/> />
@ -45,26 +41,14 @@ function DocumentCover({ doc, onTextColor }: { doc: YDoc; onTextColor: (color: s
[onTextColor] [onTextColor]
); );
if (!pageCover && !cover?.cover_selection) return null; if (!coverType || !coverValue) {
return null;
}
return ( return (
<div className={`relative flex h-[255px] w-full max-sm:h-[180px]`}> <div className={'relative flex h-[255px] w-full max-sm:h-[180px]'}>
{pageCover ? ( {coverType === 'color' && renderCoverColor(coverValue)}
<> {(coverType === 'custom' || coverType === 'built_in') && renderCoverImage(coverValue)}
{[CoverType.NormalColor, CoverType.GradientColor].includes(pageCover.type)
? renderCoverColor(pageCover.value)
: null}
{CoverType.BuildInImage === pageCover.type ? renderCoverImage(DefaultImage) : null}
{[CoverType.CustomImage, CoverType.UpsplashImage].includes(pageCover.type)
? renderCoverImage(pageCover.value)
: null}
</>
) : cover?.cover_selection ? (
<>
{cover.cover_selection_type === DocCoverType.Asset ? renderCoverImage(DefaultImage) : null}
{cover.cover_selection_type === DocCoverType.Color ? renderCoverColor(cover.cover_selection) : null}
{cover.cover_selection_type === DocCoverType.Image ? renderCoverImage(cover.cover_selection) : null}
</>
) : null}
</div> </div>
); );
} }

View File

@ -1,7 +1,16 @@
import { YDoc, YjsFolderKey } from '@/application/collab.type'; import { DocCoverType, YDoc, YjsFolderKey } from '@/application/collab.type';
import { useViewSelector } from '@/application/folder-yjs'; import { useViewSelector } from '@/application/folder-yjs';
import { CoverType } from '@/application/folder-yjs/folder.type';
import { usePageInfo } from '@/components/_shared/page/usePageInfo';
import DocumentCover from '@/components/document/document_header/DocumentCover'; import DocumentCover from '@/components/document/document_header/DocumentCover';
import { useBlockCover } from '@/components/document/document_header/useBlockCover';
import React, { memo, useMemo, useRef, useState } from 'react'; import React, { memo, useMemo, useRef, useState } from 'react';
import BuiltInImage1 from '@/assets/cover/m_cover_image_1.png';
import BuiltInImage2 from '@/assets/cover/m_cover_image_2.png';
import BuiltInImage3 from '@/assets/cover/m_cover_image_3.png';
import BuiltInImage4 from '@/assets/cover/m_cover_image_4.png';
import BuiltInImage5 from '@/assets/cover/m_cover_image_5.png';
import BuiltInImage6 from '@/assets/cover/m_cover_image_6.png';
export function DocumentHeader({ viewId, doc }: { viewId: string; doc: YDoc }) { export function DocumentHeader({ viewId, doc }: { viewId: string; doc: YDoc }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@ -16,19 +25,59 @@ export function DocumentHeader({ viewId, doc }: { viewId: string; doc: YDoc }) {
} }
}, [icon]); }, [icon]);
const { extra } = usePageInfo(viewId);
const pageCover = extra.cover;
const { cover } = useBlockCover(doc);
const coverType = useMemo(() => {
if (
(pageCover && [CoverType.NormalColor, CoverType.GradientColor].includes(pageCover.type)) ||
cover?.cover_selection_type === DocCoverType.Color
) {
return 'color';
}
if (CoverType.BuildInImage === pageCover?.type || cover?.cover_selection_type === DocCoverType.Asset) {
return 'built_in';
}
if (
(pageCover && [CoverType.CustomImage, CoverType.UpsplashImage].includes(pageCover.type)) ||
cover?.cover_selection_type === DocCoverType.Image
) {
return 'custom';
}
}, [cover?.cover_selection_type, pageCover]);
const coverValue = useMemo(() => {
if (coverType === 'built_in') {
return {
1: BuiltInImage1,
2: BuiltInImage2,
3: BuiltInImage3,
4: BuiltInImage4,
5: BuiltInImage5,
6: BuiltInImage6,
}[pageCover?.value as string];
}
return pageCover?.value || cover?.cover_selection;
}, [coverType, cover?.cover_selection, pageCover]);
return ( return (
<div ref={ref} className={'document-header mb-[10px] select-none'}> <div ref={ref} className={'document-header mb-[10px] select-none'}>
<div className={'view-banner relative flex w-full flex-col overflow-hidden'}> <div className={'view-banner relative flex w-full flex-col overflow-hidden'}>
<DocumentCover onTextColor={setTextColor} doc={doc} /> <DocumentCover onTextColor={setTextColor} coverType={coverType} coverValue={coverValue} />
<div className={`relative mx-16 w-[964px] min-w-0 max-w-full overflow-visible max-md:mx-4`}> <div className={`relative mx-16 w-[964px] min-w-0 max-w-full overflow-visible max-md:mx-4`}>
<div <div
style={{ style={{
position: 'absolute', position: coverValue ? 'absolute' : 'relative',
bottom: '100%', bottom: '100%',
width: '100%', width: '100%',
}} }}
className={'flex items-center gap-2 px-14 pb-10 text-4xl max-md:px-2 max-md:pb-6 max-sm:text-[7vw]'} className={'flex items-center gap-2 px-14 py-8 text-4xl max-md:px-2 max-sm:text-[7vw]'}
> >
<div className={`view-icon`}>{iconObject?.value}</div> <div className={`view-icon`}>{iconObject?.value}</div>
<div className={'flex flex-1 items-center gap-2 overflow-hidden'}> <div className={'flex flex-1 items-center gap-2 overflow-hidden'}>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

View File

@ -3,7 +3,7 @@ import { Leaf } from '@/components/editor/components/leaf';
import { useEditorContext } from '@/components/editor/EditorContext'; import { useEditorContext } from '@/components/editor/EditorContext';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { NodeEntry } from 'slate'; import { NodeEntry } from 'slate';
import { Editable, ReactEditor } from 'slate-react'; import { Editable, ReactEditor, RenderElementProps } from 'slate-react';
import { Element } from './components/element'; import { Element } from './components/element';
const EditorEditable = ({ editor }: { editor: ReactEditor }) => { const EditorEditable = ({ editor }: { editor: ReactEditor }) => {
@ -17,13 +17,15 @@ const EditorEditable = ({ editor }: { editor: ReactEditor }) => {
[codeDecorate] [codeDecorate]
); );
const renderElement = useCallback((props: RenderElementProps) => <Element {...props} />, []);
return ( return (
<Editable <Editable
role={'textbox'} role={'textbox'}
decorate={decorate} decorate={decorate}
className={'px-16 outline-none focus:outline-none max-md:px-4'} className={'px-16 outline-none focus:outline-none max-md:px-4'}
renderLeaf={Leaf} renderLeaf={Leaf}
renderElement={Element} renderElement={renderElement}
readOnly={readOnly} readOnly={readOnly}
spellCheck={false} spellCheck={false}
autoCorrect={'off'} autoCorrect={'off'}

View File

@ -1,12 +1,16 @@
import { YDoc } from '@/application/collab.type'; import { YDoc, YFolder, YjsEditorKey } from '@/application/collab.type';
import { DocumentTest } from '@/../cypress/support/document'; import { DocumentTest } from '@/../cypress/support/document';
import { applyYDoc } from '@/application/ydoc/apply'; import { applyYDoc } from '@/application/ydoc/apply';
import { FolderProvider } from '@/components/_shared/context-provider/FolderProvider';
import React from 'react'; import React from 'react';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { Editor } from './Editor'; import { Editor } from './Editor';
import withAppWrapper from '@/components/app/withAppWrapper'; import withAppWrapper from '@/components/app/withAppWrapper';
describe('<Editor />', () => { describe('<Editor />', () => {
beforeEach(() => {
cy.viewport(1280, 720);
});
it('renders with a paragraph', () => { it('renders with a paragraph', () => {
const documentTest = new DocumentTest(); const documentTest = new DocumentTest();
@ -16,21 +20,39 @@ describe('<Editor />', () => {
}); });
it('renders with a full document', () => { it('renders with a full document', () => {
cy.fixture('full_doc').then((docJson) => { cy.mockDatabase();
Object.defineProperty(window.navigator, 'language', { value: 'en-US' });
Object.defineProperty(window.navigator, 'languages', { value: ['en-US'] });
cy.fixture('folder').then((folderJson) => {
const doc = new Y.Doc(); const doc = new Y.Doc();
const state = new Uint8Array(docJson.data.doc_state); const state = new Uint8Array(folderJson.data.doc_state);
applyYDoc(doc, state); applyYDoc(doc, state);
renderEditor(doc);
const folder = doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.folder) as YFolder;
cy.fixture('full_doc').then((docJson) => {
const doc = new Y.Doc();
const state = new Uint8Array(docJson.data.doc_state);
applyYDoc(doc, state);
renderEditor(doc, folder);
});
}); });
}); });
}); });
function renderEditor(doc: YDoc) { function renderEditor(doc: YDoc, folder?: YFolder) {
const AppWrapper = withAppWrapper(() => { const AppWrapper = withAppWrapper(() => {
return ( return (
<div className={'h-screen w-screen overflow-y-auto'}> <div className={'h-screen w-screen overflow-y-auto'}>
<Editor doc={doc} readOnly /> {folder ? (
<FolderProvider folder={folder}>
<Editor doc={doc} readOnly />
</FolderProvider>
) : (
<Editor doc={doc} readOnly />
)}
</div> </div>
); );
}); });

View File

@ -1,7 +1,7 @@
import { YDoc } from '@/application/collab.type'; import { YDoc } from '@/application/collab.type';
import CollaborativeEditor from '@/components/editor/CollaborativeEditor'; import CollaborativeEditor from '@/components/editor/CollaborativeEditor';
import { defaultLayoutStyle, EditorContextProvider, EditorLayoutStyle } from '@/components/editor/EditorContext'; import { defaultLayoutStyle, EditorContextProvider, EditorLayoutStyle } from '@/components/editor/EditorContext';
import React from 'react'; import React, { memo } from 'react';
import './editor.scss'; import './editor.scss';
export interface EditorProps { export interface EditorProps {
@ -10,12 +10,12 @@ export interface EditorProps {
layoutStyle?: EditorLayoutStyle; layoutStyle?: EditorLayoutStyle;
} }
export const Editor = ({ readOnly, doc, layoutStyle = defaultLayoutStyle }: EditorProps) => { export const Editor = memo(({ readOnly, doc, layoutStyle = defaultLayoutStyle }: EditorProps) => {
return ( return (
<EditorContextProvider layoutStyle={layoutStyle} readOnly={readOnly}> <EditorContextProvider layoutStyle={layoutStyle} readOnly={readOnly}>
<CollaborativeEditor doc={doc} /> <CollaborativeEditor doc={doc} />
</EditorContextProvider> </EditorContextProvider>
); );
}; });
export default Editor; export default Editor;

View File

@ -22,5 +22,6 @@ export const CodeBlock = memo(
</div> </div>
</> </>
); );
}) }),
(prevProps, nextProps) => JSON.stringify(prevProps.node) === JSON.stringify(nextProps.node)
); );

View File

@ -1,9 +1,13 @@
import { ReactComponent as ExpandMoreIcon } from '$icons/16x/full_view.svg'; import { ReactComponent as ExpandMoreIcon } from '$icons/16x/full_view.svg';
import { useNavigateToView } from '@/application/folder-yjs'; import { useNavigateToView } from '@/application/folder-yjs';
import { IdProvider, useId } from '@/components/_shared/context-provider/IdProvider'; import { getCurrentWorkspace } from '@/application/services/js-services/storage';
import { IdProvider } from '@/components/_shared/context-provider/IdProvider';
import { Database } from '@/components/database'; import { Database } from '@/components/database';
import { useGetDatabaseId, useLoadDatabase } from '@/components/database/Database.hooks';
import { DatabaseContextProvider } from '@/components/database/DatabaseContext';
import { DatabaseNode, EditorElementProps } from '@/components/editor/editor.type'; import { DatabaseNode, EditorElementProps } from '@/components/editor/editor.type';
import { Tooltip } from '@mui/material'; import { Tooltip } from '@mui/material';
import CircularProgress from '@mui/material/CircularProgress';
import React, { forwardRef, memo, useCallback, useMemo, useState } from 'react'; import React, { forwardRef, memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { BlockType } from '@/application/collab.type'; import { BlockType } from '@/application/collab.type';
@ -12,11 +16,10 @@ export const DatabaseBlock = memo(
forwardRef<HTMLDivElement, EditorElementProps<DatabaseNode>>(({ node, children, ...attributes }, ref) => { forwardRef<HTMLDivElement, EditorElementProps<DatabaseNode>>(({ node, children, ...attributes }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const viewId = node.data.view_id; const viewId = node.data.view_id;
const workspaceId = useId()?.workspaceId;
const type = node.type; const type = node.type;
const navigateToView = useNavigateToView(); const navigateToView = useNavigateToView();
const [isHovering, setIsHovering] = useState(false); const [isHovering, setIsHovering] = useState(false);
const [databaseViewId, setDatabaseViewId] = useState<string | undefined>(viewId);
const style = useMemo(() => { const style = useMemo(() => {
const style = {}; const style = {};
@ -37,13 +40,22 @@ export const DatabaseBlock = memo(
}, [type]); }, [type]);
const handleNavigateToRow = useCallback( const handleNavigateToRow = useCallback(
(viewId: string, rowId: string) => { async (rowId: string) => {
const url = `/view/${workspaceId}/${viewId}?r=${rowId}`; const workspace = await getCurrentWorkspace();
if (!workspace) return;
const url = `/view/${workspace.id}/${databaseViewId}?r=${rowId}`;
window.open(url, '_blank'); window.open(url, '_blank');
}, },
[workspaceId] [databaseViewId]
); );
const databaseId = useGetDatabaseId(viewId);
const { doc, rows, notFound } = useLoadDatabase({
databaseId,
});
return ( return (
<> <>
@ -57,9 +69,17 @@ export const DatabaseBlock = memo(
{children} {children}
</div> </div>
<div contentEditable={false} style={style} className={`container-bg relative flex w-full flex-col px-3`}> <div contentEditable={false} style={style} className={`container-bg relative flex w-full flex-col px-3`}>
{viewId ? ( {viewId && doc && rows ? (
<IdProvider workspaceId={workspaceId} objectId={viewId}> <IdProvider objectId={viewId}>
<Database onNavigateToRow={handleNavigateToRow} /> <DatabaseContextProvider
navigateToRow={handleNavigateToRow}
viewId={databaseViewId || viewId}
databaseDoc={doc}
rowDocMap={rows}
readOnly={true}
>
<Database iidIndex={viewId} viewId={databaseViewId || viewId} onNavigateToView={setDatabaseViewId} />
</DatabaseContextProvider>
{isHovering && ( {isHovering && (
<div className={'absolute right-4 top-1'}> <div className={'absolute right-4 top-1'}>
<Tooltip placement={'bottom'} title={t('tooltip.openAsPage')}> <Tooltip placement={'bottom'} title={t('tooltip.openAsPage')}>
@ -80,15 +100,22 @@ export const DatabaseBlock = memo(
<div <div
className={'mt-[10%] flex h-full w-full flex-col items-center gap-2 px-16 text-text-caption max-md:px-4'} className={'mt-[10%] flex h-full w-full flex-col items-center gap-2 px-16 text-text-caption max-md:px-4'}
> >
<div className={'text-sm font-medium'}>{t('document.plugins.database.noDataSource')}</div> {notFound ? (
<div className={'text-xs'}>{t('grid.relation.noDatabaseSelected')}</div> <>
<div className={'text-sm font-medium'}>{t('document.plugins.database.noDataSource')}</div>
<div className={'text-xs'}>{t('grid.relation.noDatabaseSelected')}</div>
</>
) : (
<CircularProgress />
)}
</div> </div>
)} )}
</div> </div>
</div> </div>
</> </>
); );
}) }),
(prevProps, nextProps) => prevProps.node.data.view_id === nextProps.node.data.view_id
); );
export default DatabaseBlock; export default DatabaseBlock;

View File

@ -38,7 +38,8 @@ export const MathEquation = memo(
</> </>
); );
} }
) ),
(prevProps, nextProps) => JSON.stringify(prevProps.node) === JSON.stringify(nextProps.node)
); );
export default MathEquation; export default MathEquation;

Some files were not shown because too many files have changed in this diff Show More