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
4
.github/workflows/web2_ci.yaml
vendored
@ -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: |
|
||||
|
@ -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
|
||||
|
@ -6,4 +6,5 @@ tsconfig.json
|
||||
**/backend/**
|
||||
vite.config.ts
|
||||
**/*.cy.tsx
|
||||
*.config.ts
|
||||
*.config.ts
|
||||
coverage/
|
@ -4,4 +4,5 @@ src-tauri/
|
||||
.eslintrc.cjs
|
||||
tsconfig.json
|
||||
src/application/services/tauri-services/
|
||||
vite.config.ts
|
||||
vite.config.ts
|
||||
coverage/
|
5
frontend/appflowy_web_app/.gitignore
vendored
@ -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
|
22
frontend/appflowy_web_app/.nycrc
Normal 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"
|
||||
}
|
@ -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`
|
||||
|
@ -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"
|
||||
}
|
@ -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"]}]
|
@ -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]}
|
1
frontend/appflowy_web_app/cypress/fixtures/folder.json
Normal 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."}
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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 />)
|
||||
|
||||
|
@ -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,
|
||||
};
|
@ -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"
|
||||
|
31
frontend/appflowy_web_app/scripts/merge-coverage.cjs
Normal 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`);
|
||||
}
|
||||
|
@ -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('');
|
||||
});
|
||||
});
|
@ -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"
|
||||
}
|
||||
}
|
@ -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\":[]}"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
@ -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"
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
@ -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');
|
||||
});
|
||||
});
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>) => {
|
||||
|
@ -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() {
|
||||
|
@ -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(',');
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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>) {
|
||||
|
@ -1,8 +1,9 @@
|
||||
export enum CoverType {
|
||||
NormalColor = 'color',
|
||||
GradientColor = 'gradient',
|
||||
BuildInImage = 'none',
|
||||
BuildInImage = 'built_in',
|
||||
CustomImage = 'custom',
|
||||
LocalImage = 'local',
|
||||
UpsplashImage = 'unsplash',
|
||||
None = 'none',
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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(
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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<{
|
||||
|
@ -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>;
|
||||
}> {
|
||||
|
@ -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)) {
|
||||
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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: [],
|
||||
|
@ -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 [];
|
||||
}
|
@ -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');
|
||||
}
|
@ -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 [];
|
||||
}
|
@ -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 [];
|
||||
}
|
@ -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[];
|
||||
}
|
BIN
frontend/appflowy_web_app/src/assets/cover/m_cover_image_1.png
Normal file
After Width: | Height: | Size: 2.2 MiB |
BIN
frontend/appflowy_web_app/src/assets/cover/m_cover_image_2.png
Normal file
After Width: | Height: | Size: 731 KiB |
BIN
frontend/appflowy_web_app/src/assets/cover/m_cover_image_3.png
Normal file
After Width: | Height: | Size: 465 KiB |
BIN
frontend/appflowy_web_app/src/assets/cover/m_cover_image_4.png
Normal file
After Width: | Height: | Size: 526 KiB |
BIN
frontend/appflowy_web_app/src/assets/cover/m_cover_image_5.png
Normal file
After Width: | Height: | Size: 293 KiB |
BIN
frontend/appflowy_web_app/src/assets/cover/m_cover_image_6.png
Normal file
After Width: | Height: | Size: 765 KiB |
@ -3,7 +3,6 @@ import { useContext, createContext } from 'react';
|
||||
export const IdContext = createContext<IdProviderProps | null>(null);
|
||||
|
||||
interface IdProviderProps {
|
||||
workspaceId: string;
|
||||
objectId: string;
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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 };
|
||||
}
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
@ -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'}>
|
||||
|
@ -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} />
|
||||
))}
|
||||
|
@ -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} />,
|
||||
|
@ -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;
|
||||
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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)
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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'}>
|
||||
|
@ -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);
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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];
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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'}>
|
||||
|
Before Width: | Height: | Size: 275 KiB |
@ -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'}
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -22,5 +22,6 @@ export const CodeBlock = memo(
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})
|
||||
}),
|
||||
(prevProps, nextProps) => JSON.stringify(prevProps.node) === JSON.stringify(nextProps.node)
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -38,7 +38,8 @@ export const MathEquation = memo(
|
||||
</>
|
||||
);
|
||||
}
|
||||
)
|
||||
),
|
||||
(prevProps, nextProps) => JSON.stringify(prevProps.node) === JSON.stringify(nextProps.node)
|
||||
);
|
||||
|
||||
export default MathEquation;
|
||||
|