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
run: |
pnpm install
- name: test and lint
- name: Run lint check
working-directory: frontend/appflowy_web_app
run: |
pnpm run lint
pnpm run test:unit
- name: build and analyze
working-directory: frontend/appflowy_web_app
run: |

View File

@ -1,4 +1,4 @@
name: Cypress Tests
name: Web Code Coverage
on:
pull_request:
@ -13,7 +13,7 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
cypress-run:
test:
if: github.event.pull_request.draft != true
runs-on: ubuntu-22.04
steps:
@ -45,4 +45,15 @@ jobs:
component: true
build: pnpm run build
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/**
vite.config.ts
**/*.cy.tsx
*.config.ts
*.config.ts
coverage/

View File

@ -4,4 +4,5 @@ src-tauri/
.eslintrc.cjs
tsconfig.json
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/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 registerCodeCoverageTasks from '@cypress/code-coverage/task';
export default defineConfig({
env: {
codeCoverage: {
exclude: ['cypress/**/*.*', '**/__tests__/**/*.*', '**/*.test.*'],
},
},
component: {
devServer: {
framework: 'react',
bundler: 'vite',
},
setupNodeEvents(on, config) {
registerCodeCoverageTasks(on, config);
return config;
},
supportFile: 'cypress/support/component.ts',
},
retries: {
// 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 --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
import { YDoc } from '@/application/collab.type';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @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', () => {
cy.fixture('sign_in_success').then((json) => {
cy.intercept('GET', `/api/user/verify/${json.access_token}`, {
@ -45,3 +50,71 @@ Cypress.Commands.add('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 '@cypress/code-coverage/support';
import './commands';
import './document';
// Alternatively you can use CommonJS syntax:
@ -31,6 +32,10 @@ declare global {
interface Chainable {
mount: typeof mount;
mockAPI: () => void;
mockDatabase: () => void;
mockCurrentWorkspace: () => void;
mockGetWorkspaceDatabases: () => void;
mockDocument: (id: string) => void;
}
}
}
@ -39,3 +44,4 @@ Cypress.Commands.add('mount', mount);
// Example use:
// cy.mount(<MyComponent />)

View File

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

View File

@ -19,7 +19,10 @@
"cypress:open": "cypress open",
"test": "pnpm run test:unit && pnpm run test:components",
"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": {
"@appflowyinc/client-api-wasm": "0.0.3",
@ -86,6 +89,7 @@
"slate": "^0.101.4",
"slate-history": "^0.100.0",
"slate-react": "^0.101.3",
"smooth-scroll-into-view-if-needed": "^2.0.2",
"ts-results": "^3.3.0",
"unsplash-js": "^7.0.19",
"utf8": "^3.0.0",
@ -95,6 +99,9 @@
"yjs": "^13.6.14"
},
"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",
"@tauri-apps/cli": "^1.5.11",
"@types/google-protobuf": "^3.15.12",
@ -132,7 +139,9 @@
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"istanbul-lib-coverage": "^3.2.2",
"jest-environment-jsdom": "^29.6.2",
"nyc": "^15.1.0",
"postcss": "^8.4.21",
"prettier": "2.8.4",
"prettier-plugin-tailwindcss": "^0.2.2",
@ -147,6 +156,7 @@
"vite": "^5.2.0",
"vite-plugin-compression2": "^1.0.0",
"vite-plugin-importer": "^0.2.5",
"vite-plugin-istanbul": "^6.0.2",
"vite-plugin-svgr": "^3.2.0",
"vite-plugin-terminal": "^1.2.0",
"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 { 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 getCell = (rowId: string, fieldId: string, rowMetas: Y.Map<YDoc>) => {

View File

@ -64,7 +64,7 @@ export const useDatabaseView = () => {
const database = useDatabase();
const viewId = useViewId();
return viewId ? database.get(YjsDatabaseKey.views)?.get(viewId) : undefined;
return viewId ? database?.get(YjsDatabaseKey.views)?.get(viewId) : undefined;
};
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) {
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 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) {
if (SelectOptionFilterCondition.OptionIsEmpty === condition) {
return data === '';
}
if (SelectOptionFilterCondition.OptionIsNotEmpty === condition) {
return data !== '';
}
const selectedOptionIds = data.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 { sortBy } from '@/application/database-yjs/sort';
import { useViewsIdSelector } from '@/application/folder-yjs';
import { useId } from '@/components/_shared/context-provider/IdProvider';
import { parseYDatabaseCellToCell } from '@/components/database/components/cell/cell.parse';
import { DateTimeCell } from '@/components/database/components/cell/cell.type';
import dayjs from 'dayjs';
@ -44,10 +43,10 @@ export interface Row {
const defaultVisible = [FieldVisibility.AlwaysShown, FieldVisibility.HideWhenEmpty];
export function useDatabaseViewsSelector() {
export function useDatabaseViewsSelector(iidIndex: string) {
const database = useDatabase();
const { objectId: currentViewId } = useId();
const { viewsId: visibleViewsId, views: folderViews } = useViewsIdSelector();
const views = database?.get(YjsDatabaseKey.views);
const [viewIds, setViewIds] = useState<string[]>([]);
const childViews = useMemo(() => {
@ -58,16 +57,33 @@ export function useDatabaseViewsSelector() {
if (!views) return;
const observerEvent = () => {
setViewIds(
Array.from(views.keys()).filter((id) => {
const view = folderViews?.get(id);
const viewsObj = views.toJSON();
return (
visibleViewsId.includes(id) &&
(view?.get(YjsFolderKey.bid) === currentViewId || view?.get(YjsFolderKey.id) === currentViewId)
);
})
);
const viewsSorted = Object.entries(viewsObj).sort((a, b) => {
const [, viewA] = a;
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();
@ -76,7 +92,7 @@ export function useDatabaseViewsSelector() {
return () => {
views.unobserve(observerEvent);
};
}, [visibleViewsId, views, folderViews, currentViewId]);
}, [visibleViewsId, views, folderViews, iidIndex]);
return {
childViews,

View File

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

View File

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

View File

@ -10,7 +10,9 @@ export function useViewsIdSelector() {
const meta = folder?.get(YjsFolderKey.meta);
useEffect(() => {
if (!views) return;
if (!views) {
return;
}
const trashUid = trash ? Array.from(trash.keys())[0] : 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 {
batchCollabs,
getCollabStorage,
getCollabStorageWithAPICall,
getUserWorkspace,
getCurrentWorkspace,
} from '@/application/services/js-services/storage';
import { DatabaseService } from '@/application/services/services.type';
import * as Y from 'yjs';
@ -17,14 +17,43 @@ export class JSDatabaseService implements DatabaseService {
//
}
async getDatabase(
workspaceId: string,
currentWorkspace() {
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,
rowIds?: string[]
): Promise<{
databaseDoc: 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 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) {
try {
await batchCollabs(

View File

@ -1,5 +1,5 @@
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';
export class JSDocumentService implements DocumentService {
@ -7,8 +7,14 @@ export class JSDocumentService implements DocumentService {
//
}
async openDocument(workspaceId: string, docId: string): Promise<YDoc> {
const doc = await getCollabStorageWithAPICall(workspaceId, docId, CollabType.Document);
async openDocument(docId: string): Promise<YDoc> {
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) => {
if (origin === CollabOrigin.LocalSync) {

View File

@ -110,7 +110,7 @@ export async function batchCollabs(
const { doc } = await getCollabStorage(id, type);
applyYDoc(doc, data);
applyYDoc(doc, new Uint8Array(data));
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 workspaceKey = 'workspace';
@ -34,3 +34,10 @@ export async function setUserWorkspace(workspace: UserWorkspace) {
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[] }>;
const result: Record<string, Uint8Array> = {};
const result: Record<string, number[]> = {};
res.forEach((value, key) => {
result[key] = new Uint8Array(value.doc_state);
result[key] = value.doc_state;
});
return result;
}

View File

@ -31,20 +31,12 @@ export interface AuthService {
}
export interface DocumentService {
openDocument: (workspaceId: string, docId: string) => Promise<YDoc>;
openDocument: (docId: string) => Promise<YDoc>;
}
export interface DatabaseService {
getWorkspaceDatabases: () => Promise<{ views: string[]; database_id: string }[]>;
openDatabase: (
workspaceId: string,
viewId: string,
rowIds?: string[]
) => Promise<{
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
}>;
getDatabase: (
workspaceId: string,
databaseId: string,
rowIds?: string[]
) => 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) {
return Promise.reject('Not implemented');
}
async openDatabase(
_workspaceId: string,
_viewId: string
): Promise<{
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
}> {
return Promise.reject('Not implemented');
}
async getDatabase(
_workspaceId: string,
_databaseId: string
): Promise<{
async openDatabase(_viewId: string): Promise<{
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
}> {

View File

@ -1,6 +1,5 @@
import { CollabOrigin, YjsEditorKey, YSharedRoot } from '@/application/collab.type';
import { applySlateOp } from '@/application/slate-yjs/utils/applySlateOpts';
import { translateYjsEvent } from 'src/application/slate-yjs/utils/translateYjsEvent';
import { applyToYjs } from '@/application/slate-yjs/utils/applyToYjs';
import { Editor, Operation, Descendant } from 'slate';
import Y, { YEvent, Transaction } from 'yjs';
import { yDocToSlateContent } from '@/application/slate-yjs/utils/convert';
@ -57,12 +56,11 @@ export const YjsEditor = {
export function withYjs<T extends Editor>(
editor: T,
doc: Y.Doc,
{
localOrigin,
}: {
opts?: {
localOrigin: CollabOrigin;
}
): T & YjsEditor {
const { localOrigin = CollabOrigin.Local } = opts ?? {};
const e = editor as T & YjsEditor;
const { apply, onChange } = e;
@ -76,23 +74,34 @@ export function withYjs<T extends Editor>(
}
e.children = content.children;
console.log('initializeDocumentContent', doc.getMap(YjsEditorKey.data_section).toJSON(), e.children);
Editor.normalize(editor, { force: true });
};
e.applyRemoteEvents = (events: Array<YEvent<YSharedRoot>>, _: Transaction) => {
YjsEditor.flushLocalChanges(e);
const applyIntercept = (op: Operation) => {
if (YjsEditor.connected(e)) {
YjsEditor.storeLocalChange(e, op);
}
// TODO: handle remote events
// This is a temporary implementation to apply remote events to slate
apply(op);
};
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();
Editor.withoutNormalizing(editor, () => {
events.forEach((event) => {
translateYjsEvent(e.sharedRoot, editor, event).forEach((op) => {
// apply remote events to slate, don't call e.apply here because e.apply has been overridden.
apply(op);
});
});
});
// Restore the apply function to store local changes after applying remote changes
e.apply = applyIntercept;
};
const handleYEvents = (events: Array<YEvent<YSharedRoot>>, transaction: Transaction) => {
@ -133,18 +142,12 @@ export function withYjs<T extends Editor>(
// parse changes and apply to ydoc
doc.transact(() => {
changes.forEach((change) => {
applySlateOp(doc, { children: change.slateContent }, change.op);
applyToYjs(doc, { children: change.slateContent }, change.op);
});
}, localOrigin);
};
e.apply = (op) => {
if (YjsEditor.connected(e)) {
YjsEditor.storeLocalChange(e, op);
}
apply(op);
};
e.apply = applyIntercept;
e.onChange = () => {
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,
BlockType,
} from '@/application/collab.type';
import { BlockJson } from '@/application/slate-yjs/utils/types';
import { getFontFamily } from '@/utils/font';
import { uniq } from 'lodash-es';
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 {
const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
console.log(sharedRoot.toJSON());
const document = sharedRoot.get(YjsEditorKey.document);
const pageId = document.get(YjsEditorKey.page_id) as string;
const blocks = document.get(YjsEditorKey.blocks) as YBlocks;
@ -129,6 +121,7 @@ export function blockToSlateNode(block: BlockJson): Element {
return {
blockId: block.id,
relationId: block.children,
data: blockData,
type: block.ty,
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);
interface IdProviderProps {
workspaceId: 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 React from 'react';
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();
return (
@ -15,8 +16,11 @@ export function RecordNotFound({ open, workspaceId, title }: { workspaceId: stri
</DialogContent>
<DialogActions className={'flex w-full items-center justify-center'}>
<Button
onClick={() => {
navigate(`/view/${workspaceId}`);
onClick={async () => {
const workspace = await getCurrentWorkspace();
if (!workspace) return;
navigate(`/view/${workspace.id}`);
}}
>
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 { 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 }) => {
const { objectId, workspaceId } = useId() || {};
const [search, setSearch] = useSearchParams();
import React, { memo } from 'react';
const viewId = search.get('v');
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 databaseService = useContext(AFConfigContext)?.service?.databaseService;
const handleOpenDatabase = useCallback(async () => {
if (!databaseService || !workspaceId || !objectId) return;
try {
setDoc(null);
const { databaseDoc, rows } = await databaseService.openDatabase(workspaceId, objectId);
console.log('databaseDoc', databaseDoc.getMap(YjsEditorKey.data_section).toJSON());
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) {
export const Database = memo(
({
viewId,
onNavigateToView,
iidIndex,
}: {
iidIndex: string;
viewId: string;
onNavigateToView: (viewId: string) => void;
}) => {
console.log('Database', viewId, iidIndex);
return (
<div className={'flex h-full w-full items-center justify-center'}>
<CircularProgress />
<div className='appflowy-database relative flex w-full flex-1 select-text flex-col overflow-y-hidden'>
<DatabaseViews iidIndex={iidIndex} onChangeView={onNavigateToView} viewId={viewId} />
</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;

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 { AFConfigContext } from '@/components/app/AppConfig';
import { DatabaseRowProperties, DatabaseRowSubDocument } from '@/components/database/components/database-row';
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 CircularProgress from '@mui/material/CircularProgress';
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>
);
}
import React, { Suspense } from 'react';
export function DatabaseRow({ rowId }: { rowId: string }) {
return (
<div className={'flex w-full justify-center'}>
<div className={'max-w-screen w-[964px] min-w-0'}>
<div className={' relative flex flex-col gap-4'}>
<DatabaseContextProvider
isDatabaseRowPage={true}
viewId={objectId}
databaseDoc={doc}
rowDocMap={rows}
readOnly={true}
>
<DatabaseRowHeader rowId={rowId} />
<DatabaseRowHeader rowId={rowId} />
<div className={'flex flex-1 flex-col gap-4'}>
<Suspense>
<DatabaseRowProperties rowId={rowId} />
</Suspense>
<Divider className={'mx-16 max-md:mx-4'} />
<Suspense fallback={<ComponentLoading />}>
<DatabaseRowSubDocument rowId={rowId} />
</Suspense>
</div>
</DatabaseContextProvider>
<div className={'flex flex-1 flex-col gap-4'}>
<Suspense>
<DatabaseRowProperties rowId={rowId} />
</Suspense>
<Divider className={'mx-16 max-md:mx-4'} />
<Suspense fallback={<ComponentLoading />}>
<DatabaseRowSubDocument rowId={rowId} />
</Suspense>
</div>
</div>
</div>
</div>

View File

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

View File

@ -16,7 +16,7 @@ export function Board() {
}
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) => (
<Group key={groupId} groupId={groupId} />
))}

View File

@ -8,7 +8,7 @@ export function Calendar() {
const { dayPropGetter, localizer, formats, events, emptyEvents } = useCalendarSetup();
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
components={{
toolbar: (props) => <Toolbar {...props} emptyEvents={emptyEvents} />,

View File

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

View File

@ -1,8 +1,6 @@
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 { getPlatform } from '@/utils/platform';
import React, { useEffect, useMemo } from 'react';
import React, { memo, useEffect, useMemo } from 'react';
export interface CardProps {
groupFieldId: string;
@ -11,11 +9,10 @@ export interface CardProps {
isDragging?: boolean;
}
export function Card({ groupFieldId, rowId, onResize, isDragging }: CardProps) {
export const Card = memo(({ groupFieldId, rowId, onResize, isDragging }: CardProps) => {
const fields = useFieldsSelector();
const showFields = useMemo(() => fields.filter((field) => field.fieldId !== groupFieldId), [fields, groupFieldId]);
const [isHovering, setIsHovering] = React.useState(false);
const ref = React.useRef<HTMLDivElement | null>(null);
useEffect(() => {
@ -35,35 +32,24 @@ export function Card({ groupFieldId, rowId, onResize, isDragging }: CardProps) {
};
}, [onResize, isDragging]);
const isMobile = useMemo(() => {
return getPlatform().isMobile;
}, []);
const navigateToRow = useNavigateToRow();
return (
<div
onClick={() => {
if (isMobile) {
navigateToRow?.(rowId);
}
navigateToRow?.(rowId);
}}
ref={ref}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
style={{
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) => {
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>
);
}
});
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 { useRenderColumn } from '@/components/database/components/board/column/useRenderColumn';
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 { VariableSizeList } from 'react-window';
@ -14,86 +14,89 @@ export interface ColumnProps {
fieldId: string;
}
export function Column({ id, rows, fieldId }: ColumnProps) {
const { header } = useRenderColumn(id, fieldId);
const ref = React.useRef<VariableSizeList | null>(null);
const forceUpdate = useCallback((index: number) => {
ref.current?.resetAfterIndex(index, true);
}, []);
export const Column = memo(
({ id, rows, fieldId }: ColumnProps) => {
const { header } = useRenderColumn(id, fieldId);
const ref = React.useRef<VariableSizeList | null>(null);
const forceUpdate = useCallback((index: number) => {
ref.current?.resetAfterIndex(index, true);
}, []);
useEffect(() => {
forceUpdate(0);
}, [rows, forceUpdate]);
useEffect(() => {
forceUpdate(0);
}, [rows, forceUpdate]);
const measureRows = useMemo(
() =>
rows?.map((row) => {
return {
rowId: row.id,
const measureRows = useMemo(
() =>
rows?.map((row) => {
return {
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(
({ index, style, data }: { index: number; style: React.CSSProperties; data: Row[] }) => {
const item = data[index];
return <ListItem fieldId={fieldId} onResize={onResizeCallback} item={item} style={style} />;
},
[fieldId, onResize]
);
// We are rendering an extra item for the placeholder
if (!item) {
return null;
}
const getItemSize = useCallback(
(index: number) => {
if (!rows || index >= rows.length) return 0;
const row = rows[index];
const onResizeCallback = (height: number) => {
onResize(index, 0, {
width: 0,
height: height + 8,
});
};
if (!row) return 0;
return rowHeight(index);
},
[rowHeight, rows]
);
const rowCount = rows?.length || 0;
return <ListItem fieldId={fieldId} onResize={onResizeCallback} item={item} style={style} />;
},
[fieldId, onResize]
);
return (
<div key={id} className='column flex w-[230px] flex-col gap-4'>
<div className='column-header flex h-[24px] items-center text-xs font-medium'>
<Tag label={header?.name} color={header?.color} />
</div>
const getItemSize = useCallback(
(index: number) => {
if (!rows || index >= rows.length) return 0;
const row = rows[index];
if (!row) return 0;
return rowHeight(index);
},
[rowHeight, rows]
);
const rowCount = rows?.length || 0;
return (
<div key={id} className='column flex w-[230px] flex-col gap-4'>
<div className='column-header flex h-[24px] items-center text-xs font-medium'>
<Tag label={header?.name} color={header?.color} />
<div className={'w-full flex-1 overflow-hidden'}>
<AutoSizer>
{({ 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>
<div className={'w-full flex-1 overflow-hidden'}>
<AutoSizer>
{({ 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>
);
}
);
},
(prev, next) => JSON.stringify(prev) === JSON.stringify(next)
);

View File

@ -1,29 +1,33 @@
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';
export const ListItem = ({
item,
style,
onResize,
fieldId,
}: {
item?: Row;
style?: React.CSSProperties;
fieldId: string;
onResize?: (height: number) => void;
}) => {
return (
<div
style={{
...style,
width: 'calc(100% - 2px)',
}}
className={`w-full bg-bg-body`}
>
{item?.id ? <Card onResize={onResize} rowId={item.id} groupFieldId={fieldId} /> : null}
</div>
);
};
export const ListItem = memo(
({
item,
style,
onResize,
fieldId,
}: {
item?: Row;
style?: React.CSSProperties;
fieldId: string;
onResize?: (height: number) => void;
}) => {
return (
<div
style={{
...style,
width: 'calc(100% - 2px)',
}}
className={`w-full bg-bg-body`}
>
{item?.id ? <Card onResize={onResize} rowId={item.id} groupFieldId={fieldId} /> : null}
</div>
);
},
areEqual
);
export default ListItem;

View File

@ -6,29 +6,27 @@ import {
useFieldSelector,
useNavigateToRow,
} 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 { 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 }) {
const { field } = useFieldSelector(fieldId);
const currentDatabaseId = useDatabase()?.get(YjsDatabaseKey.id);
const workspaceId = useId()?.workspaceId;
const { onOpenDatabase, onCloseDatabase } = useGetDatabaseDispatch();
const rowIds = useMemo(() => {
return (cell.data?.toJSON() as RelationCellData) ?? [];
}, [cell.data]);
const databaseId = rowIds.length > 0 && field ? parseRelationTypeOption(field).database_id : undefined;
const databaseService = useContext(AFConfigContext)?.service?.databaseService;
const [databasePrimaryFieldId, setDatabasePrimaryFieldId] = useState<string | undefined>(undefined);
const [rows, setRows] = useState<DatabaseContextState['rowDocMap'] | null>();
const navigateToRow = useNavigateToRow();
useEffect(() => {
if (!workspaceId || !databaseId || !rowIds.length) return;
void databaseService?.getDatabase(workspaceId, databaseId, rowIds).then(({ databaseDoc: doc, rows }) => {
if (!databaseId || !rowIds.length) return;
void onOpenDatabase({ databaseId, rowIds }).then(({ databaseDoc: doc, rows }) => {
const fields = doc
.getMap(YjsEditorKey.data_section)
.get(YjsEditorKey.database)
@ -42,15 +40,15 @@ function RelationItems({ style, cell, fieldId }: { cell: RelationCell; fieldId:
setRows(rows);
});
}, [workspaceId, databaseId, databaseService, rowIds]);
}, [onOpenDatabase, databaseId, rowIds, onCloseDatabase]);
useEffect(() => {
return () => {
if (currentDatabaseId !== databaseId && databaseId) {
void databaseService?.closeDatabase(databaseId);
onCloseDatabase(databaseId);
}
};
}, [currentDatabaseId, databaseId, databaseService]);
}, [databaseId, currentDatabaseId, onCloseDatabase]);
return (
<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 { useRowMetaSelector } from '@/application/database-yjs';
import { useId } from '@/components/_shared/context-provider/IdProvider';
import { AFConfigContext } from '@/components/app/AppConfig';
import { Editor } from '@/components/editor';
import CircularProgress from '@mui/material/CircularProgress';
import React, { useCallback, useContext, useEffect, useState } from 'react';
export function DatabaseRowSubDocument({ rowId }: { rowId: string }) {
const { workspaceId } = useId() || {};
const meta = useRowMetaSelector(rowId);
const documentId = meta?.documentId;
console.log('documentId', documentId);
const [loading, setLoading] = useState(true);
const [doc, setDoc] = useState<YDoc | null>(null);
const documentService = useContext(AFConfigContext)?.service?.documentService;
const handleOpenDocument = useCallback(async () => {
if (!documentService || !workspaceId || !documentId) return;
if (!documentService || !documentId) return;
try {
setDoc(null);
const doc = await documentService.openDocument(workspaceId, documentId);
const doc = await documentService.openDocument(documentId);
console.log('doc', doc);
setDoc(doc);
@ -29,7 +26,7 @@ export function DatabaseRowSubDocument({ rowId }: { rowId: string }) {
console.error(e);
// haven't created by client, ignore error and show empty
}
}, [documentService, workspaceId, documentId]);
}, [documentService, documentId]);
useEffect(() => {
setLoading(true);

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { GridChildComponentProps, VariableSizeGrid } from 'react-window';
import React, { memo, useEffect, useRef } from 'react';
import { areEqual, GridChildComponentProps, VariableSizeGrid } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GridColumnType, RenderColumn, GridColumn } from '../grid-column';
@ -10,24 +10,25 @@ export interface GridHeaderProps {
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) => {
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(() => {
if (ref.current) {

View File

@ -1,5 +1,6 @@
import { areEqual } from 'react-window';
import { GridColumnType } from '../grid-column';
import React from 'react';
import React, { memo } from 'react';
import GridCell from '../grid-cell/GridCell';
export interface GridRowCellProps {
@ -11,7 +12,7 @@ export interface GridRowCellProps {
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) {
return (
<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;
}
}, areEqual);
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 { useMeasureHeight } from '@/components/database/components/cell/useMeasure';
import { GridColumnType, RenderColumn } from '../grid-column';
import { GridCalculateRowCell, GridRowCell, RenderRowType, useRenderRows } from '../grid-row';
import React, { useCallback, useEffect, useRef } from 'react';
@ -18,7 +18,11 @@ export interface GridTableProps {
export const GridTable = ({ scrollLeft, columnWidth, columns, onScrollLeft }: GridTableProps) => {
const ref = useRef<VariableSizeGrid | null>(null);
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(() => {
if (ref.current) {
@ -32,40 +36,6 @@ export const GridTable = ({ scrollLeft, columnWidth, columns, onScrollLeft }: Gr
}
}, [columns]);
const rowHeight = useCallback(
(index: number) => {
const row = rows[index];
if (!row || !row.rowId) return DEFAULT_ROW_HEIGHT;
return rowHeights.current[row.rowId] || DEFAULT_ROW_HEIGHT;
},
[rows]
);
const setRowHeight = useCallback(
(index: number, height: number) => {
const row = rows[index];
const rowId = row.rowId;
if (!row || !rowId) return;
const oldHeight = rowHeights.current[rowId];
rowHeights.current[rowId] = Math.max(oldHeight || DEFAULT_ROW_HEIGHT, height);
if (oldHeight !== height) {
ref.current?.resetAfterRowIndex(index, true);
}
},
[rows]
);
const onResize = useCallback(
(rowIndex: number, columnIndex: number, size: { width: number; height: number }) => {
setRowHeight(rowIndex, size.height);
},
[setRowHeight]
);
const getItemKey = useCallback(
({ columnIndex, rowIndex }: { columnIndex: number; rowIndex: number }) => {
const row = rows[rowIndex];

View File

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

View File

@ -29,7 +29,7 @@ export function Grid() {
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} />
<div className={'grid-scroll-table w-full flex-1'}>
<GridTable

View File

@ -12,7 +12,7 @@ import React, { Suspense, useCallback, useContext, useEffect, useMemo, useState
import RecordNotFound from 'src/components/_shared/not-found/RecordNotFound';
export const Document = () => {
const { objectId: documentId, workspaceId } = useId() || {};
const { objectId: documentId } = useId() || {};
const [doc, setDoc] = useState<YDoc | null>(null);
const [notFound, setNotFound] = useState<boolean>(false);
const extra = usePageInfo(documentId).extra;
@ -27,17 +27,17 @@ export const Document = () => {
const documentService = useContext(AFConfigContext)?.service?.documentService;
const handleOpenDocument = useCallback(async () => {
if (!documentService || !workspaceId || !documentId) return;
if (!documentService || !documentId) return;
try {
setDoc(null);
const doc = await documentService.openDocument(workspaceId, documentId);
const doc = await documentService.openDocument(documentId);
setDoc(doc);
} catch (e) {
Log.error(e);
setNotFound(true);
}
}, [documentService, workspaceId, documentId]);
}, [documentService, documentId]);
useEffect(() => {
setNotFound(false);
@ -105,7 +105,7 @@ export const Document = () => {
</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 { renderColor } from '@/utils/color';
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) => {
return (
<div
style={{
backgroundColor: renderColor(color),
background: renderColor(color),
}}
className={`h-full w-full`}
/>
@ -45,26 +41,14 @@ function DocumentCover({ doc, onTextColor }: { doc: YDoc; onTextColor: (color: s
[onTextColor]
);
if (!pageCover && !cover?.cover_selection) return null;
if (!coverType || !coverValue) {
return null;
}
return (
<div className={`relative flex h-[255px] w-full max-sm:h-[180px]`}>
{pageCover ? (
<>
{[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 className={'relative flex h-[255px] w-full max-sm:h-[180px]'}>
{coverType === 'color' && renderCoverColor(coverValue)}
{(coverType === 'custom' || coverType === 'built_in') && renderCoverImage(coverValue)}
</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 { CoverType } from '@/application/folder-yjs/folder.type';
import { usePageInfo } from '@/components/_shared/page/usePageInfo';
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 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 }) {
const ref = useRef<HTMLDivElement>(null);
@ -16,19 +25,59 @@ export function DocumentHeader({ viewId, doc }: { viewId: string; doc: YDoc }) {
}
}, [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 (
<div ref={ref} className={'document-header mb-[10px] select-none'}>
<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
style={{
position: 'absolute',
position: coverValue ? 'absolute' : 'relative',
bottom: '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={'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 React, { useCallback } from 'react';
import { NodeEntry } from 'slate';
import { Editable, ReactEditor } from 'slate-react';
import { Editable, ReactEditor, RenderElementProps } from 'slate-react';
import { Element } from './components/element';
const EditorEditable = ({ editor }: { editor: ReactEditor }) => {
@ -17,13 +17,15 @@ const EditorEditable = ({ editor }: { editor: ReactEditor }) => {
[codeDecorate]
);
const renderElement = useCallback((props: RenderElementProps) => <Element {...props} />, []);
return (
<Editable
role={'textbox'}
decorate={decorate}
className={'px-16 outline-none focus:outline-none max-md:px-4'}
renderLeaf={Leaf}
renderElement={Element}
renderElement={renderElement}
readOnly={readOnly}
spellCheck={false}
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 { applyYDoc } from '@/application/ydoc/apply';
import { FolderProvider } from '@/components/_shared/context-provider/FolderProvider';
import React from 'react';
import * as Y from 'yjs';
import { Editor } from './Editor';
import withAppWrapper from '@/components/app/withAppWrapper';
describe('<Editor />', () => {
beforeEach(() => {
cy.viewport(1280, 720);
});
it('renders with a paragraph', () => {
const documentTest = new DocumentTest();
@ -16,21 +20,39 @@ describe('<Editor />', () => {
});
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 state = new Uint8Array(docJson.data.doc_state);
const state = new Uint8Array(folderJson.data.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(() => {
return (
<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>
);
});

View File

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

View File

@ -22,5 +22,6 @@ export const CodeBlock = memo(
</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 { 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 { useGetDatabaseId, useLoadDatabase } from '@/components/database/Database.hooks';
import { DatabaseContextProvider } from '@/components/database/DatabaseContext';
import { DatabaseNode, EditorElementProps } from '@/components/editor/editor.type';
import { Tooltip } from '@mui/material';
import CircularProgress from '@mui/material/CircularProgress';
import React, { forwardRef, memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { BlockType } from '@/application/collab.type';
@ -12,11 +16,10 @@ export const DatabaseBlock = memo(
forwardRef<HTMLDivElement, EditorElementProps<DatabaseNode>>(({ node, children, ...attributes }, ref) => {
const { t } = useTranslation();
const viewId = node.data.view_id;
const workspaceId = useId()?.workspaceId;
const type = node.type;
const navigateToView = useNavigateToView();
const [isHovering, setIsHovering] = useState(false);
const [databaseViewId, setDatabaseViewId] = useState<string | undefined>(viewId);
const style = useMemo(() => {
const style = {};
@ -37,13 +40,22 @@ export const DatabaseBlock = memo(
}, [type]);
const handleNavigateToRow = useCallback(
(viewId: string, rowId: string) => {
const url = `/view/${workspaceId}/${viewId}?r=${rowId}`;
async (rowId: string) => {
const workspace = await getCurrentWorkspace();
if (!workspace) return;
const url = `/view/${workspace.id}/${databaseViewId}?r=${rowId}`;
window.open(url, '_blank');
},
[workspaceId]
[databaseViewId]
);
const databaseId = useGetDatabaseId(viewId);
const { doc, rows, notFound } = useLoadDatabase({
databaseId,
});
return (
<>
@ -57,9 +69,17 @@ export const DatabaseBlock = memo(
{children}
</div>
<div contentEditable={false} style={style} className={`container-bg relative flex w-full flex-col px-3`}>
{viewId ? (
<IdProvider workspaceId={workspaceId} objectId={viewId}>
<Database onNavigateToRow={handleNavigateToRow} />
{viewId && doc && rows ? (
<IdProvider objectId={viewId}>
<DatabaseContextProvider
navigateToRow={handleNavigateToRow}
viewId={databaseViewId || viewId}
databaseDoc={doc}
rowDocMap={rows}
readOnly={true}
>
<Database iidIndex={viewId} viewId={databaseViewId || viewId} onNavigateToView={setDatabaseViewId} />
</DatabaseContextProvider>
{isHovering && (
<div className={'absolute right-4 top-1'}>
<Tooltip placement={'bottom'} title={t('tooltip.openAsPage')}>
@ -80,15 +100,22 @@ export const DatabaseBlock = memo(
<div
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>
<div className={'text-xs'}>{t('grid.relation.noDatabaseSelected')}</div>
{notFound ? (
<>
<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>
</>
);
})
}),
(prevProps, nextProps) => prevProps.node.data.view_id === nextProps.node.data.view_id
);
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;

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