Feat/appflowy tauri 2 (#1902)

* chore: rename classes to models

* refactor: add effects and reducers folder

* chore: update user data storage path

* chore: subscribe callback

* chore: nav items persist, board layout (#1879)

* chore: load workspace items, load folders and pages from workspace, load raw document data, load raw grid data

* chore: clear folders and pages before load, new folder event

* chore: update folder name backend call

* chore: folder expand animation

* chore: hide arrow on empty folder

* chore: Board page layout, board store, board sample data

* chore: board block item

* chore: test db id

* chore: persist new page, persist page rename, create workspace on read error

* chore: boardblockitem details btn

* chore: boardblockitem multiselect data and colors

* chore: board item drag

* chore: drag start on move

* chore: remove databaseId

* chore: remove databaseId

* chore: import service classes into auth hook

* chore: sign out option

* chore: login page event

* chore: signup event

* chore: make workspace hook to use service

* chore: page and folder hooks use backend services

* chore: new folder use backend service

* chore: error handler page

* chore: try catch hooks to show error page

* chore: install i18n package and use flutters i18n files

* fix: signin signup margin

* chore: fix compile errors

* chore: remove unused codes

* chore: open workspace after user register

* chore: open workspace after user register

* chore: add create grid demo

* chore: load the cell data

* chore: print the cell data

* chore: fix project errors

* fix: tauri UI issues (#1899)

* chore: load workspace items, load folders and pages from workspace, load raw document data, load raw grid data

* chore: clear folders and pages before load, new folder event

* chore: update folder name backend call

* chore: folder expand animation

* chore: hide arrow on empty folder

* chore: Board page layout, board store, board sample data

* chore: board block item

* chore: test db id

* chore: persist new page, persist page rename, create workspace on read error

* chore: boardblockitem details btn

* chore: boardblockitem multiselect data and colors

* chore: board item drag

* chore: drag start on move

* chore: remove databaseId

* chore: remove databaseId

* chore: import service classes into auth hook

* chore: sign out option

* chore: login page event

* chore: signup event

* chore: make workspace hook to use service

* chore: page and folder hooks use backend services

* chore: new folder use backend service

* chore: error handler page

* chore: try catch hooks to show error page

* chore: install i18n package and use flutters i18n files

* fix: signin signup margin

* fix: new page overflow with folder

* fix: sign out button

* fix: sign out icon

* chore: floating navigation panel

* refactor: notify with error

* chore: config window size

* fix: test demo error

* chore: update tests

---------

Co-authored-by: Askarbek Zadauly <ascarbek@gmail.com>
This commit is contained in:
Nathan.fooo 2023-02-28 22:42:41 +08:00 committed by GitHub
parent 085ef8f668
commit f6957fb160
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
106 changed files with 2850 additions and 518 deletions

View File

@ -169,8 +169,8 @@
"label": "AF: Tauri UI Dev",
"type": "shell",
"isBackground": true,
"command": "npm run dev",
"problemMatcher": "$tsc-watch",
"command": "yarn",
"args": ["dev"],
"options": {
"cwd": "${workspaceFolder}/appflowy_tauri"
}
@ -179,7 +179,6 @@
"label": "AF: Tauri UI Build",
"type": "shell",
"command": "npm run build",
"problemMatcher": "$tsc-watch",
"options": {
"cwd": "${workspaceFolder}/appflowy_tauri"
}
@ -187,8 +186,7 @@
{
"label": "AF: Tauri Dev",
"type": "shell",
"command": "npm run tauri dev",
"problemMatcher": "$tsc-watch",
"command": "npm run tauri:dev",
"options": {
"cwd": "${workspaceFolder}/appflowy_tauri"
}
@ -199,8 +197,7 @@
"command": "cargo make tauri_clean",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$tsc-watch"
}
},
{
"label": "AF: Tauri Clean + Dev",

View File

@ -17,10 +17,13 @@
"@reduxjs/toolkit": "^1.9.2",
"@tauri-apps/api": "^1.2.0",
"google-protobuf": "^3.21.2",
"i18next": "^22.4.10",
"i18next-browser-languagedetector": "^7.0.1",
"jest": "^29.4.3",
"nanoid": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^12.2.0",
"react-redux": "^8.0.5",
"react-router-dom": "^6.8.0",
"react18-input-otp": "^1.1.2",
@ -44,9 +47,9 @@
"eslint": "^8.34.0",
"eslint-plugin-react": "^7.32.2",
"postcss": "^8.4.21",
"prettier": "^2.8.3",
"prettier": "2.8.4",
"prettier-plugin-tailwindcss": "^0.2.2",
"tailwindcss": "^3.2.4",
"tailwindcss": "^3.2.7",
"ts-jest": "^29.0.5",
"typescript": "^4.6.4",
"vite": "^4.0.0"

View File

@ -88,6 +88,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-utils",
"tracing",
]

View File

@ -17,6 +17,7 @@ tauri-build = { version = "1.2", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2", features = ["shell-open"] }
tauri-utils = "1.2"
bytes = { version = "1.0" }
tracing = { version = "0.1", features = ["log"] }
lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = ["use_serde"] }

View File

@ -1,10 +1,21 @@
use flowy_core::{get_client_server_configuration, AppFlowyCore, AppFlowyCoreConfig};
pub fn init_flowy_core() -> AppFlowyCore {
let data_path = tauri::api::path::data_dir().unwrap();
let path = format!("{}/AppFlowy", data_path.to_str().unwrap());
let config_json = include_str!("../tauri.conf.json");
let config: tauri_utils::config::Config = serde_json::from_str(config_json).unwrap();
let mut data_path = tauri::api::path::app_local_data_dir(&config).unwrap();
if cfg!(debug_assertions) {
data_path.push("dev");
}
data_path.push("data");
let server_config = get_client_server_configuration().unwrap();
let config = AppFlowyCoreConfig::new(&path, "AppFlowy".to_string(), server_config)
.log_filter("trace", vec!["appflowy_tauri".to_string()]);
let config = AppFlowyCoreConfig::new(
data_path.to_str().unwrap(),
"AppFlowy".to_string(),
server_config,
)
.log_filter("trace", vec!["appflowy_tauri".to_string()]);
AppFlowyCore::new(config)
}

View File

@ -60,10 +60,11 @@
"windows": [
{
"fullscreen": false,
"height": 600,
"height": 1000,
"resizable": true,
"title": "AppFlowy",
"width": 800
"width": 1200,
"transparent": true
}
]
}

View File

@ -1,7 +1,6 @@
import { Routes, Route, BrowserRouter } from 'react-router-dom';
import { TestColors } from './components/TestColors/TestColors';
import TestApiButton from './components/TestApiButton/TestApiButton';
import { Welcome } from './views/Welcome';
import { Provider } from 'react-redux';
import { store } from './stores/store';
@ -12,19 +11,20 @@ import { LoginPage } from './views/LoginPage';
import { ProtectedRoutes } from './components/auth/ProtectedRoutes';
import { SignUpPage } from './views/SignUpPage';
import { ConfirmAccountPage } from './views/ConfirmAccountPage';
import { ErrorHandlerPage } from './components/error/ErrorHandlerPage';
import initializeI18n from './stores/i18n/initializeI18n';
import { TestAPI } from './components/TestApiButton/TestAPI';
initializeI18n();
const App = () => {
// const location = useLocation();
// console.log(location);
return (
<BrowserRouter>
<Provider store={store}>
<Routes>
<Route path={'/'} element={<ProtectedRoutes />}>
<Route path={'/page/colors'} element={<TestColors />} />
<Route path={'/page/api-test'} element={<TestApiButton />} />
<Route path={'/page/api-test'} element={<TestAPI />} />
<Route path={'/page/document/:id'} element={<DocumentPage />} />
<Route path={'/page/board/:id'} element={<BoardPage />} />
<Route path={'/page/grid/:id'} element={<GridPage />} />
@ -33,8 +33,8 @@ const App = () => {
<Route path={'/auth/login'} element={<LoginPage />}></Route>
<Route path={'/auth/signUp'} element={<SignUpPage />}></Route>
<Route path={'/auth/confirm-account'} element={<ConfirmAccountPage />}></Route>
<Route path={'*'}>Not Found</Route>
</Routes>
<ErrorHandlerPage></ErrorHandlerPage>
</Provider>
</BrowserRouter>
);

View File

@ -0,0 +1,106 @@
import { FieldType, ViewLayoutTypePB, ViewPB, WorkspaceSettingPB } from '../../../services/backend';
import { FolderEventReadCurrentWorkspace } from '../../../services/backend/events/flowy-folder';
import { AppBackendService } from '../../stores/effects/folder/app/app_bd_svc';
import { DatabaseController } from '../../stores/effects/database/database_controller';
import { RowInfo } from '../../stores/effects/database/row/row_cache';
import { RowController } from '../../stores/effects/database/row/row_controller';
import {
CellControllerBuilder,
DateCellController,
NumberCellController,
SelectOptionCellController,
TextCellController,
} from '../../stores/effects/database/cell/controller_builder';
import assert from 'assert';
import { None, Option, Some } from 'ts-results';
// Create a database view for specific layout type
// Do not use it production code. Just for testing
export async function createTestDatabaseView(layout: ViewLayoutTypePB): Promise<ViewPB> {
const workspaceSetting: WorkspaceSettingPB = await FolderEventReadCurrentWorkspace().then((result) => result.unwrap());
const app = workspaceSetting.workspace.apps.items[0];
const appService = new AppBackendService(app.id);
return await appService.createView({ name: 'New Grid', layoutType: layout });
}
export async function openTestDatabase(viewId: string): Promise<DatabaseController> {
return new DatabaseController(viewId);
}
export async function assertTextCell(rowInfo: RowInfo, databaseController: DatabaseController, expectedContent: string) {
const cellController = await makeTextCellController(rowInfo, databaseController).then((result) => result.unwrap());
cellController.subscribeChanged({
onCellChanged: (value) => {
const cellContent = value.unwrap();
if (cellContent !== expectedContent) {
throw Error();
}
},
});
cellController.getCellData();
}
export async function editTextCell(rowInfo: RowInfo, databaseController: DatabaseController, content: string) {
const cellController = await makeTextCellController(rowInfo, databaseController).then((result) => result.unwrap());
await cellController.saveCellData(content);
}
export async function makeTextCellController(
rowInfo: RowInfo,
databaseController: DatabaseController
): Promise<Option<TextCellController>> {
const builder = await makeCellControllerBuilder(rowInfo, FieldType.RichText, databaseController).then((result) =>
result.unwrap()
);
return Some(builder.build() as TextCellController);
}
export async function makeNumberCellController(
rowInfo: RowInfo,
databaseController: DatabaseController
): Promise<Option<NumberCellController>> {
const builder = await makeCellControllerBuilder(rowInfo, FieldType.Number, databaseController).then((result) =>
result.unwrap()
);
return Some(builder.build() as NumberCellController);
}
export async function makeSingleSelectCellController(
rowInfo: RowInfo,
databaseController: DatabaseController
): Promise<Option<SelectOptionCellController>> {
const builder = await makeCellControllerBuilder(rowInfo, FieldType.SingleSelect, databaseController).then((result) =>
result.unwrap()
);
return Some(builder.build() as SelectOptionCellController);
}
export async function makeDateCellController(
rowInfo: RowInfo,
databaseController: DatabaseController
): Promise<Option<DateCellController>> {
const builder = await makeCellControllerBuilder(rowInfo, FieldType.DateTime, databaseController).then((result) =>
result.unwrap()
);
return Some(builder.build() as DateCellController);
}
export async function makeCellControllerBuilder(
rowInfo: RowInfo,
fieldType: FieldType,
databaseController: DatabaseController
): Promise<Option<CellControllerBuilder>> {
const rowCache = databaseController.databaseViewCache.getRowCache();
const cellCache = rowCache.getCellCache();
const fieldController = databaseController.fieldController;
const rowController = new RowController(rowInfo, fieldController, rowCache);
const cellByFieldId = await rowController.loadCells();
for (const cellIdentifier of cellByFieldId.values()) {
const builder = new CellControllerBuilder(cellIdentifier, cellCache, fieldController);
if (cellIdentifier.fieldType === fieldType) {
return Some(builder);
}
}
return None;
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import TestApiButton from './TestApiButton';
import { TestCreateGrid, TestCreateSelectOption, TestEditCell } from './TestGrid';
export const TestAPI = () => {
return (
<React.Fragment>
<ul className='m-6, space-y-2'>
<TestApiButton></TestApiButton>
<TestCreateGrid></TestCreateGrid>
<TestEditCell></TestEditCell>
<TestCreateSelectOption></TestCreateSelectOption>
</ul>
</React.Fragment>
);
};

View File

@ -1,6 +1,4 @@
import {
SignInPayloadPB,
} from '../../../services/backend/models/flowy-user/index';
import { SignInPayloadPB } from '../../../services/backend/models/flowy-user/index';
import { nanoid } from 'nanoid';
import { UserNotificationListener } from '../user/application/notifications';
import {
@ -119,10 +117,8 @@ const TestApiButton = () => {
return (
<>
<h1 className='text-3xl'>Welcome to AppFlowy!</h1>
<div>
<button className='rounded-md bg-gray-700 p-4' type='button' onClick={() => sendSignInEvent()}>
<button className='rounded-md bg-gray-300 p-4' type='button' onClick={() => sendSignInEvent()}>
Sign in and create sample data
</button>
</div>

View File

@ -0,0 +1,93 @@
import React from 'react';
import { SelectOptionCellDataPB, ViewLayoutTypePB } from '../../../services/backend';
import { Log } from '../../utils/log';
import {
assertTextCell,
createTestDatabaseView,
editTextCell,
makeSingleSelectCellController,
openTestDatabase,
} from './DatabaseTestHelper';
import assert from 'assert';
import { SelectOptionBackendService } from '../../stores/effects/database/cell/select_option_bd_svc';
export const TestCreateGrid = () => {
async function createBuildInGrid() {
const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
const databaseController = await openTestDatabase(view.id);
databaseController.subscribe({
onViewChanged: (databasePB) => {
Log.debug('Did receive database:' + databasePB);
},
onRowsChanged: async (rows) => {
assert(rows.length === 3);
},
onFieldsChanged: (fields) => {
assert(fields.length === 3);
},
});
await databaseController.open().then((result) => result.unwrap());
}
return TestButton('Test create build-in grid', createBuildInGrid);
};
export const TestEditCell = () => {
async function testGridRow() {
const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
const databaseController = await openTestDatabase(view.id);
databaseController.subscribe({
onRowsChanged: async (rows) => {
for (const [index, row] of rows.entries()) {
const cellContent = index.toString();
await editTextCell(row, databaseController, cellContent);
await assertTextCell(row, databaseController, cellContent);
}
},
});
await databaseController.open().then((result) => result.unwrap());
}
return TestButton('Test editing cell', testGridRow);
};
export const TestCreateSelectOption = () => {
async function testCreateOption() {
const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
const databaseController = await openTestDatabase(view.id);
databaseController.subscribe({
onRowsChanged: async (rows) => {
for (const [index, row] of rows.entries()) {
if (index === 0) {
const cellController = await makeSingleSelectCellController(row, databaseController).then((result) =>
result.unwrap()
);
cellController.subscribeChanged({
onCellChanged: (value) => {
const option: SelectOptionCellDataPB = value.unwrap();
console.log(option);
},
});
const backendSvc = new SelectOptionBackendService(cellController.cellIdentifier);
await backendSvc.createOption({ name: 'option' + index });
}
}
},
});
await databaseController.open().then((result) => result.unwrap());
}
return TestButton('Test create a select option', testCreateOption);
};
const TestButton = (title: string, onClick: () => void) => {
return (
<React.Fragment>
<div>
<button className='rounded-md bg-gray-300 p-4' type='button' onClick={() => onClick()}>
{title}
</button>
</div>
</React.Fragment>
);
};

View File

@ -0,0 +1,45 @@
import { useAppDispatch, useAppSelector } from '../../stores/store';
import { useEffect, useState } from 'react';
import { databaseActions, IDatabase } from '../../stores/reducers/database/slice';
import { nanoid } from 'nanoid';
import { FieldType } from '../../../services/backend';
export const useDatabase = () => {
const dispatch = useAppDispatch();
const database = useAppSelector((state) => state.database);
const newField = () => {
dispatch(
databaseActions.addField({
field: {
fieldId: nanoid(8),
fieldType: FieldType.RichText,
fieldOptions: {},
title: 'new field',
},
})
);
};
const renameField = (fieldId: string, newTitle: string) => {
const field = database.fields[fieldId];
field.title = newTitle;
dispatch(
databaseActions.updateField({
field,
})
);
};
const newRow = () => {
dispatch(databaseActions.addRow());
};
return {
database,
newField,
renameField,
newRow,
};
};

View File

@ -0,0 +1,44 @@
import { IPopupItem, Popup } from './Popup';
import i18n from 'i18next';
const supportedLanguages: { key: string; title: string }[] = [
{
key: 'en',
title: 'English',
},
{ key: 'ca-ES', title: 'ca-ES' },
{ key: 'de-DE', title: 'de-DE' },
{ key: 'es-VE', title: 'es-VE' },
{ key: 'eu-ES', title: 'eu-ES' },
{ key: 'fr-CA', title: 'fr-CA' },
{ key: 'fr-FR', title: 'fr-FR' },
{ key: 'hu-HU', title: 'hu-HU' },
{ key: 'id-ID', title: 'id-ID' },
{ key: 'it-IT', title: 'it-IT' },
{ key: 'ja-JP', title: 'ja-JP' },
{ key: 'ko-KR', title: 'ko-KR' },
{ key: 'pl-PL', title: 'pl-PL' },
{ key: 'pt-BR', title: 'pt-BR' },
{ key: 'pt-PT', title: 'pt-PT' },
{ key: 'ru-RU', title: 'ru-RU' },
{ key: 'sv', title: 'sv' },
{ key: 'tr-TR', title: 'tr-TR' },
{ key: 'zh-CN', title: 'zh-CN' },
{ key: 'zh-TW', title: 'zh-TW' },
];
export const LanguageSelectPopup = ({ onClose }: { onClose: () => void }) => {
const items: IPopupItem[] = supportedLanguages.map<IPopupItem>((item) => ({
onClick: () => void i18n.changeLanguage(item.key),
title: item.title,
icon: <></>,
}));
return (
<Popup
items={items}
className={'absolute top-full right-0 z-10 w-[200px]'}
onOutsideClick={onClose}
columns={2}
></Popup>
);
};

View File

@ -11,10 +11,12 @@ export const Popup = ({
items,
className = '',
onOutsideClick,
columns = 1,
}: {
items: IPopupItem[];
className: string;
onOutsideClick?: () => void;
columns?: 1 | 2 | 3;
}) => {
const ref = useRef<HTMLDivElement>(null);
useOutsideClick(ref, () => onOutsideClick && onOutsideClick());
@ -26,16 +28,22 @@ export const Popup = ({
return (
<div ref={ref} className={`${className} rounded-lg bg-white px-2 py-2 shadow-md`}>
{items.map((item, index) => (
<button
key={index}
className={'flex w-full cursor-pointer items-center rounded-lg px-2 py-2 hover:bg-main-secondary'}
onClick={(e) => handleClick(e, item)}
>
{item.icon}
<span className={'ml-2'}>{item.title}</span>
</button>
))}
<div
className={`grid ${columns === 1 && 'grid-cols-1'} ${columns === 2 && 'grid-cols-2'} ${
columns === 3 && 'grid-cols-3'
} gap-x-4`}
>
{items.map((item, index) => (
<button
key={index}
className={'flex cursor-pointer items-center gap-2 rounded-lg px-2 py-2 hover:bg-main-secondary'}
onClick={(e) => handleClick(e, item)}
>
{item.icon}
<span className={'flex-shrink-0'}>{item.title}</span>
</button>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,26 @@
import { SelectOptionColorPB } from '../../../services/backend';
export const getBgColor = (color: SelectOptionColorPB | undefined): string => {
switch (color) {
case SelectOptionColorPB.Purple:
return 'bg-tint-1';
case SelectOptionColorPB.Pink:
return 'bg-tint-2';
case SelectOptionColorPB.LightPink:
return 'bg-tint-3';
case SelectOptionColorPB.Orange:
return 'bg-tint-4';
case SelectOptionColorPB.Yellow:
return 'bg-tint-5';
case SelectOptionColorPB.Lime:
return 'bg-tint-6';
case SelectOptionColorPB.Green:
return 'bg-tint-7';
case SelectOptionColorPB.Aqua:
return 'bg-tint-8';
case SelectOptionColorPB.Blue:
return 'bg-tint-9';
default:
return '';
}
};

View File

@ -0,0 +1,16 @@
export const CloseSvg = () => {
return (
<svg width='100%' height='100%' viewBox='0 0 32 32' fill='none' xmlns='http://www.w3.org/2000/svg'>
<rect
x='20.9497'
y='9.63599'
width='2'
height='16'
rx='1'
transform='rotate(45 20.9497 9.63599)'
fill='currentColor'
/>
<rect x='22.364' y='20.95' width='2' height='16' rx='1' transform='rotate(135 22.364 20.95)' fill='currentColor' />
</svg>
);
};

View File

@ -0,0 +1,10 @@
export const DropDownShowSvg = () => {
return (
<svg width='100%' height='100%' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M11.5757 14.5757L8.52426 11.5243C8.14629 11.1463 8.41399 10.5 8.94853 10.5H15.0515C15.586 10.5 15.8537 11.1463 15.4757 11.5243L12.4243 14.5757C12.1899 14.8101 11.8101 14.8101 11.5757 14.5757Z'
fill='currentColor'
/>
</svg>
);
};

View File

@ -0,0 +1,21 @@
export const EarthSvg = () => {
return (
<svg width='100%' height='100%' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M12 19C15.866 19 19 15.866 19 12C19 8.13401 15.866 5 12 5C8.13401 5 5 8.13401 5 12C5 15.866 8.13401 19 12 19Z'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path d='M5 12H19' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round' />
<path
d='M12.0002 5C13.7511 6.91685 14.7461 9.40442 14.8002 12C14.7461 14.5956 13.7511 17.0832 12.0002 19C10.2493 17.0832 9.25427 14.5956 9.2002 12C9.25427 9.40442 10.2493 6.91685 12.0002 5V5Z'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
);
};

View File

@ -2,12 +2,12 @@ export const EyeClosed = () => {
return (
<svg width='100%' height='100%' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
fill-rule='evenodd'
clip-rule='evenodd'
fillRule='evenodd'
clipRule='evenodd'
d='M7.68372 17.3771C8.88359 18.1747 10.3278 18.75 12.0001 18.75C15.7261 18.75 18.32 15.8941 19.6011 14.0863C20.4933 12.8272 20.4933 11.1728 19.6011 9.91375C19.1009 9.208 18.4007 8.34252 17.5112 7.54957L16.4489 8.61191C17.236 9.30133 17.8836 10.0844 18.3772 10.781C18.9013 11.5205 18.9013 12.4795 18.3772 13.219C17.1411 14.9633 14.9396 17.25 12.0001 17.25C10.794 17.25 9.71218 16.865 8.77028 16.2905L7.68372 17.3771ZM7.55137 15.3881L6.48903 16.4504C5.5995 15.6575 4.8993 14.792 4.39916 14.0863C3.50692 12.8272 3.50692 11.1728 4.39916 9.91375C5.68028 8.10595 8.27417 5.25 12.0001 5.25C13.6724 5.25 15.1167 5.82531 16.3165 6.62294L15.23 7.7095C14.2881 7.13497 13.2062 6.75 12.0001 6.75C9.06064 6.75 6.85914 9.03672 5.62301 10.781C5.09897 11.5205 5.09897 12.4795 5.62301 13.219C6.11667 13.9156 6.76428 14.6987 7.55137 15.3881ZM10.4887 14.572C10.9279 14.8431 11.4439 15 12.0002 15C13.641 15 14.932 13.6349 14.932 12C14.932 11.4625 14.7925 10.9542 14.5468 10.5139L13.3964 11.6644C13.4197 11.7717 13.432 11.884 13.432 12C13.432 12.8503 12.7694 13.5 12.0002 13.5C11.868 13.5 11.739 13.4808 11.616 13.4448L10.4887 14.572ZM10.6039 12.3355L9.45347 13.486C9.20788 13.0458 9.06836 12.5375 9.06836 12C9.06836 10.3651 10.3594 9 12.0002 9C12.5564 9 13.0724 9.15686 13.5115 9.42792L12.3842 10.5552C12.2612 10.5192 12.1323 10.5 12.0002 10.5C11.231 10.5 10.5684 11.1497 10.5684 12C10.5684 12.116 10.5807 12.2282 10.6039 12.3355Z'
fill='#333333'
fill='currentColor'
/>
<path d='M17.5 5L5 17.5' stroke='#333333' strokeWidth='1.5' strokeLinecap='round' />
<path d='M17.5 5L5 17.5' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round' />
</svg>
);
};

View File

@ -3,14 +3,14 @@ export const EyeOpened = () => {
<svg width='100%' height='100%' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M5.01097 13.6526C4.30282 12.6533 4.30282 11.3467 5.01097 10.3474C6.26959 8.57133 8.66728 6 12 6C15.3327 6 17.7304 8.57133 18.989 10.3474C19.6972 11.3467 19.6972 12.6533 18.989 13.6526C17.7304 15.4287 15.3327 18 12 18C8.66728 18 6.26959 15.4287 5.01097 13.6526Z'
stroke='#333333'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M11.9999 14.25C13.2049 14.25 14.1818 13.2426 14.1818 12C14.1818 10.7574 13.2049 9.75 11.9999 9.75C10.7949 9.75 9.81812 10.7574 9.81812 12C9.81812 13.2426 10.7949 14.25 11.9999 14.25Z'
stroke='#333333'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'

View File

@ -0,0 +1,10 @@
export const HideMenuSvg = () => {
return (
<svg width='100%' height='100%' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path d='M9 9L6 12L9 15' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round' />
<rect width='5' height='1.5' rx='0.75' transform='matrix(-1 0 0 1 18 8.25)' fill='currentColor' />
<rect width='7' height='1.5' rx='0.75' transform='matrix(-1 0 0 1 18 11.25)' fill='currentColor' />
<rect width='5' height='1.5' rx='0.75' transform='matrix(-1 0 0 1 18 14.25)' fill='currentColor' />
</svg>
);
};

View File

@ -0,0 +1,14 @@
export const InformationSvg = () => {
return (
<svg width='100%' height='100%' viewBox='0 0 16 17' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M8 14.0039C11.3137 14.0039 14 11.3176 14 8.00391C14 4.6902 11.3137 2.00391 8 2.00391C4.68629 2.00391 2 4.6902 2 8.00391C2 11.3176 4.68629 14.0039 8 14.0039Z'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
/>
<rect x='7.5' y='7.00391' width='1' height='4' rx='0.5' fill='currentColor' />
<rect x='7.5' y='5.00391' width='1' height='1' rx='0.5' fill='currentColor' />
</svg>
);
};

View File

@ -0,0 +1,14 @@
export const LogoutSvg = () => {
return (
<svg width='100%' height='100%' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M6 13H4C3.73478 13 3.48043 12.8829 3.29289 12.6746C3.10536 12.4662 3 12.1836 3 11.8889V4.11111C3 3.81643 3.10536 3.53381 3.29289 3.32544C3.48043 3.11706 3.73478 3 4 3H6'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path d='M10 11L13 8L10 5' stroke='currentColor' strokeLinecap='round' strokeLinejoin='round' />
<path d='M13 8L7 8' stroke='currentColor' strokeLinecap='round' strokeLinejoin='round' />
</svg>
);
};

View File

@ -0,0 +1,10 @@
export const ShowMenuSvg = () => {
return (
<svg width='100%' height='100%' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path d='M10 5L13 8L10 11' stroke='currentColor' strokeLinecap='round' strokeLinejoin='round' />
<rect x='3' y='5' width='4' height='1' rx='0.5' fill='currentColor' />
<rect x='3' y='7.5' width='6' height='1' rx='0.5' fill='currentColor' />
<rect x='3' y='10' width='4' height='1' rx='0.5' fill='currentColor' />
</svg>
);
};

View File

@ -2,26 +2,60 @@ import { useState } from 'react';
import { currentUserActions } from '../../../stores/reducers/current-user/slice';
import { useAppDispatch, useAppSelector } from '../../../stores/store';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth.hooks';
export const useLogin = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const appDispatch = useAppDispatch();
const currentUser = useAppSelector((state) => state.currentUser);
const navigate = useNavigate();
const { login } = useAuth();
const [authError, setAuthError] = useState(false);
function onTogglePassword() {
setShowPassword(!showPassword);
}
function onSignInClick() {
appDispatch(
currentUserActions.updateUser({
...currentUser,
isAuthenticated: true,
})
);
navigate('/');
// reset error
function _setEmail(v: string) {
setAuthError(false);
setEmail(v);
}
return { showPassword, onTogglePassword, onSignInClick };
function _setPassword(v: string) {
setAuthError(false);
setPassword(v);
}
async function onSignInClick() {
try {
const result = await login(email, password);
const { id, name, token } = result;
appDispatch(
currentUserActions.updateUser({
id: id,
displayName: name,
email: email,
token: token,
isAuthenticated: true,
})
);
navigate('/');
} catch (e) {
setAuthError(true);
}
}
return {
showPassword,
onTogglePassword,
onSignInClick,
email,
setEmail: _setEmail,
password,
setPassword: _setPassword,
authError,
};
};

View File

@ -4,60 +4,97 @@ import { EyeOpened } from '../../_shared/svg/EyeOpenSvg';
import { useLogin } from './Login.hooks';
import { Link } from 'react-router-dom';
import { Button } from '../../_shared/Button';
import { useTranslation } from 'react-i18next';
import { EarthSvg } from '../../_shared/svg/EarthSvg';
import { useState } from 'react';
import { LanguageSelectPopup } from '../../_shared/LanguageSelectPopup';
export const Login = () => {
const { showPassword, onTogglePassword, onSignInClick } = useLogin();
const { showPassword, onTogglePassword, onSignInClick, email, setEmail, password, setPassword, authError } =
useLogin();
const { t } = useTranslation('');
const [showLanguagePopup, setShowLanguagePopup] = useState(false);
return (
<form onSubmit={(e) => e.preventDefault()} method='POST'>
<div className='flex h-screen w-full flex-col items-center justify-center gap-12 text-center'>
<div className='flex h-10 w-10 justify-center'>
<AppflowyLogo />
</div>
<div>
<span className='text-2xl font-semibold leading-9'>Login to Appflowy</span>
</div>
<div className='flex w-full max-w-[340px] flex-col gap-6 '>
<input type='text' className='input w-full' placeholder='Phone / Email' />
<div className='relative w-full'>
<input type={showPassword ? 'text' : 'password'} className='input w-full !pr-10' placeholder='Password' />
{/* Show password button */}
<button
type='button'
className='absolute right-0 top-0 flex h-full w-12 items-center justify-center '
onClick={onTogglePassword}
>
<span className='h-6 w-6'>{showPassword ? <EyeClosed /> : <EyeOpened />}</span>
</button>
<>
<form onSubmit={(e) => e.preventDefault()} method='POST'>
<div className='relative flex h-screen w-screen flex-col items-center justify-center gap-12 text-center'>
<div className='flex h-10 w-10 justify-center'>
<AppflowyLogo />
</div>
<div className='flex justify-center'>
{/* Forget password link */}
<Link to={'/auth/confirm-account'}>
<span className='text-xs text-main-accent hover:text-main-hovered'>Forgot password?</span>
</Link>
</div>
</div>
<div className='flex w-full max-w-[340px] flex-col gap-6 '>
<Button size={'primary'} onClick={() => onSignInClick()}>
Login
</Button>
{/* signup link */}
<div className='flex justify-center'>
<span className='text-xs text-gray-400'>
Don't have an account?
<Link to={'/auth/signUp'}>
<span className='text-main-accent hover:text-main-hovered'> Sign up</span>
</Link>
<div>
<span className='text-2xl font-semibold leading-9'>
{t('signIn.loginTitle').replace('@:appName', 'AppFlowy')}
</span>
</div>
<div className='flex w-full max-w-[340px] flex-col gap-6 '>
<input
type='text'
className={`input w-full ${authError && 'error'}`}
placeholder={t('signIn.emailHint') || ''}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<div className='relative w-full'>
{/* Password input field */}
<input
type={showPassword ? 'text' : 'password'}
className={`input w-full !pr-10 ${authError && 'error'}`}
placeholder={t('signIn.passwordHint') || ''}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{/* Show password button */}
<button
type='button'
className='absolute right-0 top-0 flex h-full w-12 items-center justify-center '
onClick={onTogglePassword}
>
<span className='h-6 w-6'>{showPassword ? <EyeClosed /> : <EyeOpened />}</span>
</button>
</div>
<div className='flex justify-center'>
{/* Forget password link */}
<Link to={'/auth/confirm-account'}>
<span className='text-xs text-main-accent hover:text-main-hovered'>{t('signIn.forgotPassword')}</span>
</Link>
</div>
</div>
<div className='flex w-full max-w-[340px] flex-col gap-6 '>
<Button size={'primary'} onClick={() => onSignInClick()}>
{t('signIn.loginButtonText')}
</Button>
{/* signup link */}
<div className='flex justify-center'>
<span className='text-xs text-gray-400'>
{t('signIn.dontHaveAnAccount')}
<Link to={'/auth/signUp'}>
<span className='ml-2 text-main-accent hover:text-main-hovered'>{t('signUp.buttonText')}</span>
</Link>
</span>
</div>
</div>
<div className={'absolute right-0 top-0 px-12 py-8'}>
<div className={'relative h-full w-full'}>
<button className={'h-8 w-8 text-shade-3 hover:text-black'} onClick={() => setShowLanguagePopup(true)}>
<EarthSvg></EarthSvg>
</button>
{showLanguagePopup && (
<LanguageSelectPopup onClose={() => setShowLanguagePopup(false)}></LanguageSelectPopup>
)}
</div>
</div>
</div>
</div>
</form>
</form>
</>
);
};

View File

@ -2,13 +2,40 @@ import { useState } from 'react';
import { useAppDispatch, useAppSelector } from '../../../stores/store';
import { currentUserActions } from '../../../stores/reducers/current-user/slice';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth.hooks';
export const useSignUp = () => {
const [email, _setEmail] = useState('');
const [displayName, _setDisplayName] = useState('');
const [password, _setPassword] = useState('');
const [repeatedPassword, _setRepeatedPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const appDispatch = useAppDispatch();
const currentUser = useAppSelector((state) => state.currentUser);
const navigate = useNavigate();
const { register } = useAuth();
const [authError, setAuthError] = useState(false);
const setEmail = (v: string) => {
setAuthError(false);
_setEmail(v);
};
const setDisplayName = (v: string) => {
setAuthError(false);
_setDisplayName(v);
};
const setPassword = (v: string) => {
setAuthError(false);
_setPassword(v);
};
const setRepeatedPassword = (v: string) => {
setAuthError(false);
_setRepeatedPassword(v);
};
function onTogglePassword() {
setShowPassword(!showPassword);
@ -18,15 +45,39 @@ export const useSignUp = () => {
setShowConfirmPassword(!showConfirmPassword);
}
function onSignUpClick() {
appDispatch(
currentUserActions.updateUser({
...currentUser,
isAuthenticated: true,
})
);
navigate('/');
async function onSignUpClick() {
try {
const result = await register(email, password, displayName);
const { id, token } = result;
appDispatch(
currentUserActions.updateUser({
id,
token,
email,
displayName,
isAuthenticated: true,
})
);
navigate('/');
} catch (e) {
setAuthError(true);
}
}
return { showPassword, onTogglePassword, showConfirmPassword, onToggleConfirmPassword, onSignUpClick };
return {
email,
setEmail,
displayName,
setDisplayName,
password,
setPassword,
repeatedPassword,
setRepeatedPassword,
showPassword,
onTogglePassword,
showConfirmPassword,
onToggleConfirmPassword,
onSignUpClick,
authError,
};
};

View File

@ -5,25 +5,66 @@ import { EyeOpened } from '../../_shared/svg/EyeOpenSvg';
import { useSignUp } from './SignUp.hooks';
import { Link } from 'react-router-dom';
import { Button } from '../../_shared/Button';
import { EarthSvg } from '../../_shared/svg/EarthSvg';
import { LanguageSelectPopup } from '../../_shared/LanguageSelectPopup';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
export const SignUp = () => {
const { showPassword, onTogglePassword, showConfirmPassword, onToggleConfirmPassword, onSignUpClick } = useSignUp();
const {
showPassword,
onTogglePassword,
showConfirmPassword,
onToggleConfirmPassword,
onSignUpClick,
email,
setEmail,
displayName,
setDisplayName,
password,
setPassword,
repeatedPassword,
setRepeatedPassword,
authError,
} = useSignUp();
const { t } = useTranslation('');
const [showLanguagePopup, setShowLanguagePopup] = useState(false);
return (
<form method='POST' onSubmit={(e) => e.preventDefault()}>
<div className='flex h-screen w-full flex-col items-center justify-center gap-12 text-center'>
<div className='relative flex h-screen w-full flex-col items-center justify-center gap-12 text-center'>
<div className='flex h-10 w-10 justify-center'>
<AppflowyLogo />
</div>
<div>
<span className='text-2xl font-semibold'>Sign up to Appflowy</span>
<span className='text-2xl font-semibold'>{t('signUp.title').replace('@:appName', 'AppFlowy')}</span>
</div>
<div className='flex w-full max-w-[340px] flex-col gap-6'>
<input type='text' className='input w-full' placeholder='Phone / Email' />
<input
type='text'
className={`input w-full ${authError && 'error'}`}
placeholder={t('signUp.emailHint') || ''}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{/* new user should enter his name, need translation for this field */}
<input
type='text'
className={`input w-full ${authError && 'error'}`}
placeholder='Name'
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<div className='relative w-full'>
<input type={showPassword ? 'text' : 'password'} className='input w-full !pr-10' placeholder='Password' />
<input
type={showPassword ? 'text' : 'password'}
className={`input w-full !pr-10 ${authError && 'error'}`}
placeholder={t('signUp.passwordHint') || ''}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
className='absolute right-0 top-0 flex h-full w-12 items-center justify-center '
@ -37,8 +78,10 @@ export const SignUp = () => {
<div className='relative w-full'>
<input
type={showConfirmPassword ? 'text' : 'password'}
className='input w-full !pr-10'
placeholder='Repeat Password'
className={`input w-full !pr-10 ${authError && 'error'}`}
placeholder={t('signUp.repeatPasswordHint') || ''}
value={repeatedPassword}
onChange={(e) => setRepeatedPassword(e.target.value)}
/>
<button
@ -53,19 +96,30 @@ export const SignUp = () => {
<div className='flex w-full max-w-[340px] flex-col gap-6 '>
<Button size={'primary'} onClick={() => onSignUpClick()}>
Get Started
{t('signUp.getStartedText')}
</Button>
{/* signup link */}
<div className='flex justify-center'>
<span className='text-xs text-gray-500'>
Already have an account?
{t('signUp.alreadyHaveAnAccount')}
<Link to={'/auth/login'}>
<span className=' text-main-accent hover:text-main-hovered'> Sign in</span>
<span className='ml-2 text-main-accent hover:text-main-hovered'>{t('signIn.buttonText')}</span>
</Link>
</span>
</div>
</div>
<div className={'absolute right-0 top-0 px-12 py-8'}>
<div className={'relative h-full w-full'}>
<button className={'h-8 w-8 text-shade-3 hover:text-black'} onClick={() => setShowLanguagePopup(true)}>
<EarthSvg></EarthSvg>
</button>
{showLanguagePopup && (
<LanguageSelectPopup onClose={() => setShowLanguagePopup(false)}></LanguageSelectPopup>
)}
</div>
</div>
</div>
</form>
);

View File

@ -1,14 +1,71 @@
import { currentUserActions } from '../../stores/reducers/current-user/slice';
import { useAppDispatch, useAppSelector } from '../../stores/store';
import { UserProfilePB } from '../../../services/backend/events/flowy-user';
import { AuthBackendService } from '../../stores/effects/user/user_bd_svc';
import { FolderEventReadCurrentWorkspace } from '../../../services/backend/events/flowy-folder';
import { WorkspaceSettingPB } from '../../../services/backend/models/flowy-folder/workspace';
export const useAuth = () => {
const dispatch = useAppDispatch();
const currentUser = useAppSelector((state) => state.currentUser);
const authBackendService = new AuthBackendService();
function logout() {
async function register(email: string, password: string, name: string): Promise<UserProfilePB> {
const authResult = await authBackendService.signUp({ email, password, name });
if (authResult.ok) {
const { id, token } = authResult.val;
// Get the workspace setting after user registered. The workspace setting
// contains the latest visiting view and the current workspace data.
const openWorkspaceResult = await _openWorkspace();
if (openWorkspaceResult.ok) {
const workspaceSetting: WorkspaceSettingPB = openWorkspaceResult.val;
dispatch(
currentUserActions.updateUser({
id: id,
token: token,
email,
displayName: name,
isAuthenticated: true,
workspaceSetting: workspaceSetting,
})
);
}
return authResult.val;
} else {
console.error(authResult.val.msg);
throw new Error(authResult.val.msg);
}
}
async function login(email: string, password: string): Promise<UserProfilePB> {
const result = await authBackendService.signIn({ email, password });
if (result.ok) {
const { id, token, name } = result.val;
dispatch(
currentUserActions.updateUser({
id: id,
token: token,
email,
displayName: name,
isAuthenticated: true,
})
);
return result.val;
} else {
console.error(result.val.msg);
throw new Error(result.val.msg);
}
}
async function logout() {
await authBackendService.signOut();
dispatch(currentUserActions.logout());
}
return { currentUser, logout };
async function _openWorkspace() {
return FolderEventReadCurrentWorkspace();
}
return { currentUser, register, login, logout };
};

View File

@ -0,0 +1,65 @@
import { useEffect, useState } from 'react';
import { useAppDispatch, useAppSelector } from '../../stores/store';
import { boardActions } from '../../stores/reducers/board/slice';
import { ICellData, IDatabase, IDatabaseRow, ISelectOption } from '../../stores/reducers/database/slice';
export const useBoard = () => {
const dispatch = useAppDispatch();
const groupingFieldId = useAppSelector((state) => state.board);
const database = useAppSelector((state) => state.database);
const [title, setTitle] = useState('');
const [boardColumns, setBoardColumns] =
useState<(ISelectOption & { rows: (IDatabaseRow & { isGhost: boolean })[] })[]>();
const [movingRowId, setMovingRowId] = useState<string | undefined>(undefined);
const [ghostLocation, setGhostLocation] = useState<{ column: number; row: number }>({ column: 0, row: 0 });
useEffect(() => {
setTitle(database.title);
setBoardColumns(
database.fields[groupingFieldId].fieldOptions.selectOptions?.map((groupFieldItem) => {
const rows = database.rows
.filter((row) => row.cells[groupingFieldId].optionIds?.some((so) => so === groupFieldItem.selectOptionId))
.map((row) => ({
...row,
isGhost: false,
}));
return {
...groupFieldItem,
rows: rows,
};
}) || []
);
}, [database, groupingFieldId]);
const changeGroupingField = (fieldId: string) => {
dispatch(
boardActions.setGroupingFieldId({
fieldId,
})
);
};
const onGhostItemMove = (columnIndex: number, rowIndex: number) => {
setGhostLocation({ column: columnIndex, row: rowIndex });
};
const startMove = (rowId: string) => {
setMovingRowId(rowId);
};
const endMove = () => {
setMovingRowId(undefined);
};
return {
title,
boardColumns,
groupingFieldId,
changeGroupingField,
startMove,
endMove,
onGhostItemMove,
movingRowId,
ghostLocation,
};
};

View File

@ -0,0 +1,59 @@
import { SettingsSvg } from '../_shared/svg/SettingsSvg';
import { SearchInput } from '../_shared/SearchInput';
import { useDatabase } from '../_shared/Database.hooks';
import { BoardBlock } from './BoardBlock';
import { NewBoardBlock } from './NewBoardBlock';
import { IDatabaseRow } from '../../stores/reducers/database/slice';
import { useBoard } from './Board.hooks';
export const Board = () => {
const { database, newField, renameField, newRow } = useDatabase();
const {
title,
boardColumns,
groupingFieldId,
changeGroupingField,
startMove,
endMove,
onGhostItemMove,
movingRowId,
ghostLocation,
} = useBoard();
return (
<>
<div className='flex w-full items-center justify-between'>
<div className={'flex items-center text-xl font-semibold'}>
<div>{title}</div>
<button className={'ml-2 h-5 w-5'}>
<SettingsSvg></SettingsSvg>
</button>
</div>
<div className='flex shrink-0 items-center gap-4'>
<SearchInput />
</div>
</div>
<div className={'relative w-full flex-1 overflow-auto'}>
<div className={'absolute flex h-full flex-shrink-0 items-start justify-start gap-4'}>
{database &&
boardColumns?.map((column, index) => (
<BoardBlock
key={index}
title={column.title}
groupingFieldId={groupingFieldId}
count={column.rows.length}
fields={database.fields}
columns={database.columns}
rows={column.rows}
startMove={startMove}
endMove={endMove}
/>
))}
<NewBoardBlock onClick={() => console.log('new block')}></NewBoardBlock>
</div>
</div>
</>
);
};

View File

@ -0,0 +1,64 @@
import { Details2Svg } from '../_shared/svg/Details2Svg';
import AddSvg from '../_shared/svg/AddSvg';
import { DatabaseFieldMap, ICellData, IDatabaseColumn, IDatabaseRow } from '../../stores/reducers/database/slice';
import { BoardBlockItem } from './BoardBlockItem';
export const BoardBlock = ({
title,
groupingFieldId,
count,
fields,
columns,
rows,
startMove,
endMove,
}: {
title: string;
groupingFieldId: string;
count: number;
fields: DatabaseFieldMap;
columns: IDatabaseColumn[];
rows: IDatabaseRow[];
startMove: (id: string) => void;
endMove: () => void;
}) => {
return (
<div className={'flex h-full w-[250px] flex-col rounded-lg bg-surface-1'}>
<div className={'flex items-center justify-between p-4'}>
<div className={'flex items-center gap-2'}>
<span>{title}</span>
<span className={'text-shade-4'}>({count})</span>
</div>
<div className={'flex items-center gap-2'}>
<button className={'h-5 w-5 rounded hover:bg-surface-2'}>
<Details2Svg></Details2Svg>
</button>
<button className={'h-5 w-5 rounded hover:bg-surface-2'}>
<AddSvg></AddSvg>
</button>
</div>
</div>
<div className={'flex flex-1 flex-col gap-1 overflow-auto px-2'}>
{rows.map((row, index) => (
<BoardBlockItem
key={index}
groupingFieldId={groupingFieldId}
fields={fields}
columns={columns}
row={row}
startMove={() => startMove(row.rowId)}
endMove={() => endMove()}
></BoardBlockItem>
))}
</div>
<div className={'p-2'}>
<button className={'flex w-full items-center gap-2 rounded-lg px-2 py-2 hover:bg-surface-2'}>
<span className={'h-5 w-5'}>
<AddSvg></AddSvg>
</span>
<span>New</span>
</button>
</div>
</div>
);
};

View File

@ -0,0 +1,125 @@
import { DatabaseFieldMap, IDatabaseColumn, IDatabaseRow } from '../../stores/reducers/database/slice';
import { Details2Svg } from '../_shared/svg/Details2Svg';
import { FieldType } from '../../../services/backend';
import { getBgColor } from '../_shared/getColor';
import { MouseEventHandler, useEffect, useRef, useState } from 'react';
export const BoardBlockItem = ({
groupingFieldId,
fields,
columns,
row,
startMove,
endMove,
}: {
groupingFieldId: string;
fields: DatabaseFieldMap;
columns: IDatabaseColumn[];
row: IDatabaseRow;
startMove: () => void;
endMove: () => void;
}) => {
const [isMoving, setIsMoving] = useState(false);
const [isDown, setIsDown] = useState(false);
const [ghostWidth, setGhostWidth] = useState(0);
const [ghostHeight, setGhostHeight] = useState(0);
const [ghostLeft, setGhostLeft] = useState(0);
const [ghostTop, setGhostTop] = useState(0);
const el = useRef<HTMLDivElement>(null);
useEffect(() => {
if (el.current?.getBoundingClientRect && isMoving) {
const { left, top, width, height } = el.current.getBoundingClientRect();
setGhostWidth(width);
setGhostHeight(height);
setGhostLeft(left);
setGhostTop(top);
startMove();
const gEl = document.getElementById('ghost-block');
if (gEl?.innerHTML) {
gEl.innerHTML = el.current.innerHTML;
}
}
}, [el, isMoving]);
const onMouseMove: MouseEventHandler<HTMLDivElement> = (e) => {
setGhostLeft(ghostLeft + e.movementX);
setGhostTop(ghostTop + e.movementY);
};
const onMouseUp: MouseEventHandler<HTMLDivElement> = (e) => {
setIsMoving(false);
endMove();
};
const dragStart = () => {
if (isDown) {
setIsMoving(true);
setIsDown(false);
}
};
return (
<>
<div
ref={el}
onMouseDown={() => setIsDown(true)}
onMouseMove={dragStart}
onMouseUp={() => setIsDown(false)}
onClick={() => console.log('on click')}
className={`relative cursor-pointer select-none rounded-lg border border-shade-6 bg-white px-3 py-2 transition-transform duration-100 hover:bg-main-selector `}
>
<button className={'absolute right-4 top-2.5 h-5 w-5 rounded hover:bg-surface-2'}>
<Details2Svg></Details2Svg>
</button>
<div className={'flex flex-col gap-3'}>
{columns
.filter((column) => column.fieldId !== groupingFieldId)
.map((column, index) => {
switch (fields[column.fieldId].fieldType) {
case FieldType.MultiSelect:
return (
<div key={index} className={'flex flex-wrap items-center gap-2'}>
{row.cells[column.fieldId].optionIds?.map((option, indexOption) => {
const selectOptions = fields[column.fieldId].fieldOptions.selectOptions;
const selectedOption = selectOptions?.find((so) => so.selectOptionId === option);
return (
<div
key={indexOption}
className={`rounded px-1 py-0.5 text-sm ${getBgColor(selectedOption?.color)}`}
>
{selectedOption?.title}
</div>
);
})}
</div>
);
default:
return <div key={index}>{row.cells[column.fieldId].data}</div>;
}
})}
</div>
</div>
{isMoving && (
<div
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
id={'ghost-block'}
className={
'fixed z-10 rotate-6 scale-105 cursor-pointer select-none rounded-lg border border-shade-6 bg-white px-3 py-2'
}
style={{
width: `${ghostWidth}px`,
height: `${ghostHeight}px`,
left: `${ghostLeft}px`,
top: `${ghostTop}px`,
}}
>
&nbsp;
</div>
)}
</>
);
};

View File

@ -0,0 +1,14 @@
import AddSvg from '../_shared/svg/AddSvg';
export const NewBoardBlock = ({ onClick }: { onClick: () => void }) => {
return (
<div className={'w-[250px]'}>
<button onClick={onClick} className={'flex w-full items-center gap-2 rounded-lg px-4 py-2 hover:bg-surface-2'}>
<span className={'h-5 w-5'}>
<AddSvg></AddSvg>
</span>
<span>Add Block</span>
</button>
</div>
);
};

View File

@ -0,0 +1,30 @@
import { useAppDispatch, useAppSelector } from '../../stores/store';
import { errorActions } from '../../stores/reducers/error/slice';
import { useEffect, useState } from 'react';
export const useError = () => {
const dispatch = useAppDispatch();
const error = useAppSelector((state) => state.error);
const [errorMessage, setErrorMessage] = useState('');
const [displayError, setDisplayError] = useState(false);
useEffect(() => {
setDisplayError(error.display);
setErrorMessage(error.message);
}, [error]);
const showError = (msg: string) => {
dispatch(errorActions.showError(msg));
};
const hideError = () => {
dispatch(errorActions.hideError());
};
return {
showError,
hideError,
errorMessage,
displayError,
};
};

View File

@ -0,0 +1,8 @@
import { useError } from './Error.hooks';
import { ErrorModal } from './ErrorModal';
export const ErrorHandlerPage = () => {
const { hideError, errorMessage, displayError } = useError();
return displayError ? <ErrorModal message={errorMessage} onClose={hideError}></ErrorModal> : <></>;
};

View File

@ -0,0 +1,28 @@
import { InformationSvg } from '../_shared/svg/InformationSvg';
import { CloseSvg } from '../_shared/svg/CloseSvg';
export const ErrorModal = ({ message, onClose }: { message: string; onClose: () => void }) => {
return (
<div className={'fixed inset-0 z-20 flex items-center justify-center bg-white/30 backdrop-blur-sm'}>
<div
className={
'relative flex flex-col items-center gap-8 rounded-xl border border-shade-5 bg-white px-16 py-8 shadow-md'
}
>
<button
onClick={() => onClose()}
className={'absolute right-0 top-0 z-20 px-2 py-2 text-shade-5 hover:text-black'}
>
<i className={'block h-8 w-8'}>
<CloseSvg></CloseSvg>
</i>
</button>
<div className={'h-24 w-24 text-main-alert'}>
<InformationSvg></InformationSvg>
</div>
<h1 className={'text-xl'}>Oops.. something went wrong</h1>
<h2>{message}</h2>
</div>
</div>
);
};

View File

@ -1,8 +1,32 @@
export const AppLogo = () => {
import { HideMenuSvg } from '../_shared/svg/HideMenuSvg';
import { ShowMenuSvg } from '../_shared/svg/ShowMenuSvg';
export const AppLogo = ({
iconToShow,
onHideMenuClick,
onShowMenuClick,
}: {
iconToShow: 'hide' | 'show';
onHideMenuClick?: () => void;
onShowMenuClick?: () => void;
}) => {
return (
<div className={'mb-2 flex h-[60px] items-center justify-between px-6'}>
<img src={'/images/flowy_logo_with_text.svg'} alt={'logo'} />
<img src={'/images/home/hide_menu.svg'} alt={'hide'} />
{iconToShow === 'hide' && (
<button onClick={onHideMenuClick} className={'h-5 w-5'}>
<i>
<HideMenuSvg></HideMenuSvg>
</i>
</button>
)}
{iconToShow === 'show' && (
<button onClick={onShowMenuClick} className={'h-5 w-5'}>
<i>
<ShowMenuSvg></ShowMenuSvg>
</i>
</button>
)}
</div>
);
};

View File

@ -0,0 +1,23 @@
import { IPopupItem, Popup } from '../../_shared/Popup';
import { LogoutSvg } from '../../_shared/svg/LogoutSvg';
export const OptionsPopup = ({ onSignOutClick, onClose }: { onSignOutClick: () => void; onClose: () => void }) => {
const items: IPopupItem[] = [
{
title: 'Sign out',
icon: (
<i className={'block h-5 w-5 flex-shrink-0'}>
<LogoutSvg></LogoutSvg>
</i>
),
onClick: onSignOutClick,
},
];
return (
<Popup
className={'absolute top-[50px] right-[30px] z-10 whitespace-nowrap'}
items={items}
onOutsideClick={onClose}
></Popup>
);
};

View File

@ -0,0 +1,27 @@
import { useState } from 'react';
import { useAuth } from '../../auth/auth.hooks';
export const usePageOptions = () => {
const [showOptionsPopup, setShowOptionsPopup] = useState(false);
const { logout } = useAuth();
const onOptionsClick = () => {
setShowOptionsPopup(true);
};
const onClose = () => {
setShowOptionsPopup(false);
};
const onSignOutClick = async () => {
await logout();
onClose();
};
return {
showOptionsPopup,
onOptionsClick,
onClose,
onSignOutClick,
};
};

View File

@ -1,15 +1,23 @@
import { Button } from '../../_shared/Button';
import { Details2Svg } from '../../_shared/svg/Details2Svg';
import { usePageOptions } from './PageOptions.hooks';
import { OptionsPopup } from './OptionsPopup';
export const PageOptions = () => {
return (
<div className={'flex items-center'}>
<Button size={'small'} onClick={() => console.log('share click')}>
Share
</Button>
const { showOptionsPopup, onOptionsClick, onClose, onSignOutClick } = usePageOptions();
<button className={'ml-8'}>
<img className={'h-8 w-8'} src={`/images/editor/details.svg`} />
</button>
</div>
return (
<>
<div className={'relative flex items-center gap-4'}>
<Button size={'small'} onClick={() => console.log('share click')}>
Share
</Button>
<button className={'relative h-8 w-8'} onClick={onOptionsClick}>
<Details2Svg></Details2Svg>
</button>
</div>
{showOptionsPopup && <OptionsPopup onSignOutClick={onSignOutClick} onClose={onClose}></OptionsPopup>}
</>
);
};

View File

@ -1,11 +1,17 @@
import { foldersActions, IFolder } from '../../../stores/reducers/folders/slice';
import { useState } from 'react';
import { useAppDispatch } from '../../../stores/store';
import { nanoid } from 'nanoid';
import { pagesActions } from '../../../stores/reducers/pages/slice';
import { useEffect, useState } from 'react';
import { useAppDispatch, useAppSelector } from '../../../stores/store';
import { IPage, pagesActions } from '../../../stores/reducers/pages/slice';
import { ViewLayoutTypePB } from '../../../../services/backend';
import { AppBackendService } from '../../../stores/effects/folder/app/app_bd_svc';
import { WorkspaceBackendService } from '../../../stores/effects/folder/workspace/workspace_bd_svc';
import { useError } from '../../error/Error.hooks';
export const useFolderEvents = (folder: IFolder) => {
const initialFolderHeight = 40;
const initialPageHeight = 40;
const animationDuration = 500;
export const useFolderEvents = (folder: IFolder, pages: IPage[]) => {
const appDispatch = useAppDispatch();
const [showPages, setShowPages] = useState(false);
@ -13,7 +19,25 @@ export const useFolderEvents = (folder: IFolder) => {
const [showNewPageOptions, setShowNewPageOptions] = useState(false);
const [showRenamePopup, setShowRenamePopup] = useState(false);
const [folderHeight, setFolderHeight] = useState(`${initialFolderHeight}px`);
const workspace = useAppSelector((state) => state.workspace);
const appBackendService = new AppBackendService(folder.id);
const workspaceBackendService = new WorkspaceBackendService(workspace.id || '');
const error = useError();
useEffect(() => {
if (showPages) {
setFolderHeight(`${initialFolderHeight + pages.length * initialPageHeight}px`);
}
}, [pages]);
const onFolderNameClick = () => {
if (showPages) {
setFolderHeight(`${initialFolderHeight}px`);
} else {
setFolderHeight(`${initialFolderHeight + pages.length * initialPageHeight}px`);
}
setShowPages(!showPages);
};
@ -30,22 +54,39 @@ export const useFolderEvents = (folder: IFolder) => {
setShowRenamePopup(true);
};
const changeFolderTitle = (newTitle: string) => {
appDispatch(foldersActions.renameFolder({ id: folder.id, newTitle }));
const changeFolderTitle = async (newTitle: string) => {
try {
await appBackendService.update({ name: newTitle });
appDispatch(foldersActions.renameFolder({ id: folder.id, newTitle }));
} catch (e: any) {
error.showError(e?.message);
}
};
const closeRenamePopup = () => {
setShowRenamePopup(false);
};
const deleteFolder = () => {
const deleteFolder = async () => {
closePopup();
appDispatch(foldersActions.deleteFolder({ id: folder.id }));
try {
await appBackendService.delete();
appDispatch(foldersActions.deleteFolder({ id: folder.id }));
} catch (e: any) {
error.showError(e?.message);
}
};
const duplicateFolder = () => {
const duplicateFolder = async () => {
closePopup();
appDispatch(foldersActions.addFolder({ id: nanoid(8), title: folder.title }));
try {
const newApp = await workspaceBackendService.createApp({
name: folder.title,
});
appDispatch(foldersActions.addFolder({ id: newApp.id, title: folder.title }));
} catch (e: any) {
error.showError(e?.message);
}
};
const closePopup = () => {
@ -53,35 +94,67 @@ export const useFolderEvents = (folder: IFolder) => {
setShowNewPageOptions(false);
};
const onAddNewDocumentPage = () => {
const onAddNewDocumentPage = async () => {
closePopup();
appDispatch(
pagesActions.addPage({
folderId: folder.id,
pageType: ViewLayoutTypePB.Document,
title: 'New Page 1',
id: nanoid(6),
})
);
try {
const newView = await appBackendService.createView({
name: 'New Document 1',
layoutType: ViewLayoutTypePB.Document,
});
appDispatch(
pagesActions.addPage({
folderId: folder.id,
pageType: ViewLayoutTypePB.Document,
title: newView.name,
id: newView.id,
})
);
} catch (e: any) {
error.showError(e?.message);
}
};
const onAddNewBoardPage = () => {
const onAddNewBoardPage = async () => {
closePopup();
appDispatch(
pagesActions.addPage({
folderId: folder.id,
pageType: ViewLayoutTypePB.Board,
title: 'New Board 1',
id: nanoid(6),
})
);
try {
const newView = await appBackendService.createView({
name: 'New Board 1',
layoutType: ViewLayoutTypePB.Board,
});
appDispatch(
pagesActions.addPage({
folderId: folder.id,
pageType: ViewLayoutTypePB.Board,
title: newView.name,
id: newView.id,
})
);
} catch (e: any) {
error.showError(e?.message);
}
};
const onAddNewGridPage = () => {
const onAddNewGridPage = async () => {
closePopup();
appDispatch(
pagesActions.addPage({ folderId: folder.id, pageType: ViewLayoutTypePB.Grid, title: 'New Grid 1', id: nanoid(6) })
);
try {
const newView = await appBackendService.createView({
name: 'New Grid 1',
layoutType: ViewLayoutTypePB.Grid,
});
appDispatch(
pagesActions.addPage({
folderId: folder.id,
pageType: ViewLayoutTypePB.Grid,
title: newView.name,
id: newView.id,
})
);
} catch (e: any) {
error.showError(e?.message);
}
};
return {
@ -104,5 +177,7 @@ export const useFolderEvents = (folder: IFolder) => {
onAddNewGridPage,
closePopup,
folderHeight,
animationDuration,
};
};

View File

@ -8,6 +8,10 @@ import { IPage } from '../../../stores/reducers/pages/slice';
import { PageItem } from './PageItem';
import { Button } from '../../_shared/Button';
import { RenamePopup } from './RenamePopup';
import { useEffect, useState } from 'react';
import { DropDownShowSvg } from '../../_shared/svg/DropDownShowSvg';
let timeoutHandle: any;
export const FolderItem = ({
folder,
@ -38,46 +42,72 @@ export const FolderItem = ({
onAddNewGridPage,
closePopup,
} = useFolderEvents(folder);
folderHeight,
animationDuration,
} = useFolderEvents(folder, pages);
const [hideOverflow, setHideOverflow] = useState(!showPages);
useEffect(() => {
clearTimeout(timeoutHandle);
if (showPages) {
timeoutHandle = setTimeout(() => {
setHideOverflow(!showPages);
}, animationDuration);
} else {
setHideOverflow(!showPages);
}
}, [showPages]);
return (
<div className={'relative my-2'}>
/*transitionTimingFunction:'cubic-bezier(.36,1.55,.65,1.1)'*/
<div className={'relative'}>
<div
onClick={() => onFolderNameClick()}
className={'flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-surface-2'}
className={`relative my-2 ${hideOverflow ? 'overflow-hidden' : ''} transition-all `}
style={{ height: folderHeight, transitionDuration: `${animationDuration}ms` }}
>
<div className={'flex min-w-0 flex-1 items-center'}>
<div className={`mr-2 transition-transform duration-500 ${showPages && 'rotate-180'}`}>
<img className={''} src={'/images/home/drop_down_show.svg'} alt={''} />
<div
onClick={() => onFolderNameClick()}
className={'flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-surface-2'}
>
<button className={'flex min-w-0 flex-1 items-center'}>
<i className={`mr-2 h-5 w-5 transition-transform duration-500 ${showPages && 'rotate-180'}`}>
{pages.length > 0 && <DropDownShowSvg></DropDownShowSvg>}
</i>
<span className={'min-w-0 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap text-left'}>
{folder.title}
</span>
</button>
<div className={'relative flex items-center'}>
<Button size={'box-small-transparent'} onClick={() => onFolderOptionsClick()}>
<Details2Svg></Details2Svg>
</Button>
<Button size={'box-small-transparent'} onClick={() => onNewPageClick()}>
<AddSvg></AddSvg>
</Button>
</div>
<span className={'min-w-0 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap'}>{folder.title}</span>
</div>
<div className={'relative flex items-center'}>
<Button size={'box-small-transparent'} onClick={() => onFolderOptionsClick()}>
<Details2Svg></Details2Svg>
</Button>
<Button size={'box-small-transparent'} onClick={() => onNewPageClick()}>
<AddSvg></AddSvg>
</Button>
{showFolderOptions && (
<NavItemOptionsPopup
onRenameClick={() => startFolderRename()}
onDeleteClick={() => deleteFolder()}
onDuplicateClick={() => duplicateFolder()}
onClose={() => closePopup()}
></NavItemOptionsPopup>
)}
{showNewPageOptions && (
<NewPagePopup
onDocumentClick={() => onAddNewDocumentPage()}
onBoardClick={() => onAddNewBoardPage()}
onGridClick={() => onAddNewGridPage()}
onClose={() => closePopup()}
></NewPagePopup>
)}
</div>
{pages.map((page, index) => (
<PageItem key={index} page={page} onPageClick={() => onPageClick(page)}></PageItem>
))}
</div>
{showFolderOptions && (
<NavItemOptionsPopup
onRenameClick={() => startFolderRename()}
onDeleteClick={() => deleteFolder()}
onDuplicateClick={() => duplicateFolder()}
onClose={() => closePopup()}
></NavItemOptionsPopup>
)}
{showNewPageOptions && (
<NewPagePopup
onDocumentClick={() => onAddNewDocumentPage()}
onBoardClick={() => onAddNewBoardPage()}
onGridClick={() => onAddNewGridPage()}
onClose={() => closePopup()}
></NewPagePopup>
)}
{showRenamePopup && (
<RenamePopup
value={folder.title}
@ -85,8 +115,6 @@ export const FolderItem = ({
onClose={closeRenamePopup}
></RenamePopup>
)}
{showPages &&
pages.map((page, index) => <PageItem key={index} page={page} onPageClick={() => onPageClick(page)}></PageItem>)}
</div>
);
};

View File

@ -48,7 +48,7 @@ export const NavItemOptionsPopup = ({
<Popup
onOutsideClick={() => onClose && onClose()}
items={items}
className={'absolute right-0 top-full z-10'}
className={'absolute right-0 top-[40px] z-10'}
></Popup>
);
};

View File

@ -0,0 +1,79 @@
import { AppLogo } from '../AppLogo';
import { Workspace } from '../Workspace';
import { FolderItem } from './FolderItem';
import { PluginsButton } from './PluginsButton';
import { TrashButton } from './TrashButton';
import { NewFolderButton } from './NewFolderButton';
import { IFolder } from '../../../stores/reducers/folders/slice';
import { IPage } from '../../../stores/reducers/pages/slice';
import { useEffect, useRef, useState } from 'react';
const animationDuration = 500;
export const NavigationFloatingPanel = ({
onFixNavigationClick,
slideInFloatingPanel,
folders,
pages,
onPageClick,
setWidth,
}: {
onFixNavigationClick: () => void;
slideInFloatingPanel: boolean;
folders: IFolder[];
pages: IPage[];
onPageClick: (page: IPage) => void;
setWidth: (v: number) => void;
}) => {
const el = useRef<HTMLDivElement>(null);
const [panelLeft, setPanelLeft] = useState(0);
useEffect(() => {
if (!el?.current) return;
const { width } = el.current.getBoundingClientRect();
setWidth(width);
if (slideInFloatingPanel) {
setPanelLeft(0);
} else {
setPanelLeft(-width);
}
}, [el.current, slideInFloatingPanel]);
return (
<div
ref={el}
className={
'fixed top-16 z-10 flex flex-col justify-between rounded-tr rounded-br border border-l-0 border-shade-4 bg-white text-sm shadow-md transition-all'
}
style={{ left: panelLeft, transitionDuration: `${animationDuration}ms` }}
>
<div className={'flex flex-col'}>
<AppLogo iconToShow={'show'} onShowMenuClick={onFixNavigationClick}></AppLogo>
<Workspace></Workspace>
<div className={'flex flex-col px-2'}>
{folders.map((folder, index) => (
<FolderItem
key={index}
folder={folder}
pages={pages.filter((page) => page.folderId === folder.id)}
onPageClick={onPageClick}
></FolderItem>
))}
</div>
</div>
<div className={'flex flex-col'}>
<div className={'border-b border-shade-6 px-2 pb-4'}>
<PluginsButton></PluginsButton>
<TrashButton></TrashButton>
</div>
<NewFolderButton></NewFolderButton>
</div>
</div>
);
};

View File

@ -1,13 +1,59 @@
import { useAppSelector } from '../../../stores/store';
import { useNavigate } from 'react-router-dom';
import { IPage } from '../../../stores/reducers/pages/slice';
import { ViewLayoutTypePB } from '../../../../services/backend';
import { MouseEventHandler, useState } from 'react';
// number of pixels from left side of screen to show hidden navigation panel
const FLOATING_PANEL_SHOW_WIDTH = 10;
const FLOATING_PANEL_HIDE_EXTRA_WIDTH = 10;
export const useNavigationPanelHooks = function () {
const folders = useAppSelector((state) => state.folders);
const pages = useAppSelector((state) => state.pages);
const width = useAppSelector((state) => state.navigationWidth);
const [navigationPanelFixed, setNavigationPanelFixed] = useState(true);
const [slideInFloatingPanel, setSlideInFloatingPanel] = useState(true);
const navigate = useNavigate();
const onCollapseNavigationClick = () => {
setSlideInFloatingPanel(true);
setNavigationPanelFixed(false);
};
const onFixNavigationClick = () => {
setNavigationPanelFixed(true);
};
const [floatingPanelWidth, setFloatingPanelWidth] = useState(0);
const onPageClick = (page: IPage) => {
let pageTypeRoute = (() => {
switch (page.pageType) {
case ViewLayoutTypePB.Document:
return 'document';
break;
case ViewLayoutTypePB.Grid:
return 'grid';
case ViewLayoutTypePB.Board:
return 'board';
default:
return 'document';
}
})();
navigate(`/page/${pageTypeRoute}/${page.id}`);
};
const onScreenMouseMove: MouseEventHandler<HTMLDivElement> = (e) => {
if (e.screenX <= FLOATING_PANEL_SHOW_WIDTH) {
setSlideInFloatingPanel(true);
} else if (e.screenX > floatingPanelWidth + FLOATING_PANEL_HIDE_EXTRA_WIDTH) {
setSlideInFloatingPanel(false);
}
};
return {
width,
@ -15,5 +61,13 @@ export const useNavigationPanelHooks = function () {
pages,
navigate,
onPageClick,
onCollapseNavigationClick,
onFixNavigationClick,
navigationPanelFixed,
onScreenMouseMove,
slideInFloatingPanel,
setFloatingPanelWidth,
};
};

View File

@ -1,4 +1,3 @@
import { useNavigationPanelHooks } from './NavigationPanel.hooks';
import { Workspace } from '../Workspace';
import { AppLogo } from '../AppLogo';
import { FolderItem } from './FolderItem';
@ -6,22 +5,27 @@ import { PluginsButton } from './PluginsButton';
import { TrashButton } from './TrashButton';
import { NewFolderButton } from './NewFolderButton';
import { NavigationResizer } from './NavigationResizer';
import { IFolder } from '../../../stores/reducers/folders/slice';
import { IPage } from '../../../stores/reducers/pages/slice';
export const NavigationPanel = () => {
const {
width,
folders,
pages,
navigate,
} = useNavigationPanelHooks();
export const NavigationPanel = ({
onCollapseNavigationClick,
width,
folders,
pages,
onPageClick,
}: {
onCollapseNavigationClick: () => void;
width: number;
folders: IFolder[];
pages: IPage[];
onPageClick: (page: IPage) => void;
}) => {
return (
<>
<div className={'flex flex-col justify-between bg-surface-1 text-sm'} style={{ width: `${width}px` }}>
<div className={'flex flex-col'}>
<AppLogo></AppLogo>
<AppLogo iconToShow={'hide'} onHideMenuClick={onCollapseNavigationClick}></AppLogo>
<Workspace></Workspace>
@ -31,7 +35,7 @@ export const NavigationPanel = () => {
key={index}
folder={folder}
pages={pages.filter((page) => page.folderId === folder.id)}
onPageClick={(page) => navigate(`/page/${page.pageType}/${page.id}`)}
onPageClick={onPageClick}
></FolderItem>
))}
</div>

View File

@ -1,12 +1,23 @@
import { useAppDispatch } from '../../../stores/store';
import { useAppDispatch, useAppSelector } from '../../../stores/store';
import { foldersActions } from '../../../stores/reducers/folders/slice';
import { nanoid } from 'nanoid';
import { WorkspaceBackendService } from '../../../stores/effects/folder/workspace/workspace_bd_svc';
import { useError } from '../../error/Error.hooks';
export const useNewFolder = () => {
const appDispatch = useAppDispatch();
const workspace = useAppSelector((state) => state.workspace);
const workspaceBackendService = new WorkspaceBackendService(workspace.id || '');
const error = useError();
const onNewFolder = () => {
appDispatch(foldersActions.addFolder({ id: nanoid(8), title: 'New Folder 1' }));
const onNewFolder = async () => {
try {
const newApp = await workspaceBackendService.createApp({
name: 'New Folder 1',
});
appDispatch(foldersActions.addFolder({ id: newApp.id, title: newApp.name }));
} catch (e: any) {
error.showError(e?.message);
}
};
return {

View File

@ -48,7 +48,7 @@ export const NewPagePopup = ({
<Popup
onOutsideClick={() => onClose && onClose()}
items={items}
className={'absolute right-0 top-full z-10'}
className={'absolute right-0 top-[40px] z-10'}
></Popup>
);
};

View File

@ -2,11 +2,15 @@ import { IPage, pagesActions } from '../../../stores/reducers/pages/slice';
import { useAppDispatch } from '../../../stores/store';
import { useState } from 'react';
import { nanoid } from 'nanoid';
import { ViewBackendService } from '../../../stores/effects/folder/view/view_bd_svc';
import { useError } from '../../error/Error.hooks';
export const usePageEvents = (page: IPage) => {
const appDispatch = useAppDispatch();
const [showPageOptions, setShowPageOptions] = useState(false);
const [showRenamePopup, setShowRenamePopup] = useState(false);
const viewBackendService: ViewBackendService = new ViewBackendService(page.id);
const error = useError();
const onPageOptionsClick = () => {
setShowPageOptions(!showPageOptions);
@ -17,20 +21,34 @@ export const usePageEvents = (page: IPage) => {
closePopup();
};
const changePageTitle = (newTitle: string) => {
appDispatch(pagesActions.renamePage({ id: page.id, newTitle }));
const changePageTitle = async (newTitle: string) => {
try {
await viewBackendService.update({ name: newTitle });
appDispatch(pagesActions.renamePage({ id: page.id, newTitle }));
} catch (e: any) {
error.showError(e?.message);
}
};
const deletePage = () => {
const deletePage = async () => {
closePopup();
appDispatch(pagesActions.deletePage({ id: page.id }));
try {
await viewBackendService.delete();
appDispatch(pagesActions.deletePage({ id: page.id }));
} catch (e: any) {
error.showError(e?.message);
}
};
const duplicatePage = () => {
closePopup();
appDispatch(
pagesActions.addPage({ id: nanoid(8), pageType: page.pageType, title: page.title, folderId: page.folderId })
);
try {
appDispatch(
pagesActions.addPage({ id: nanoid(8), pageType: page.pageType, title: page.title, folderId: page.folderId })
);
} catch (e: any) {
error.showError(e?.message);
}
};
const closePopup = () => {

View File

@ -7,7 +7,7 @@ import { IPage } from '../../../stores/reducers/pages/slice';
import { Button } from '../../_shared/Button';
import { usePageEvents } from './PageItem.hooks';
import { RenamePopup } from './RenamePopup';
import { ViewLayoutTypePB } from '../../../../services/backend/models/flowy-folder/view';
import { ViewLayoutTypePB } from '../../../../services/backend';
export const PageItem = ({ page, onPageClick }: { page: IPage; onPageClick: () => void }) => {
const {
@ -28,28 +28,30 @@ export const PageItem = ({ page, onPageClick }: { page: IPage; onPageClick: () =
onClick={() => onPageClick()}
className={'flex cursor-pointer items-center justify-between rounded-lg py-2 pl-8 pr-4 hover:bg-surface-2 '}
>
<div className={'flex min-w-0 flex-1 items-center'}>
<div className={'ml-1 mr-1 h-[16px] w-[16px]'}>
<button className={'flex min-w-0 flex-1 items-center'}>
<i className={'ml-1 mr-1 h-[16px] w-[16px]'}>
{page.pageType === ViewLayoutTypePB.Document && <DocumentSvg></DocumentSvg>}
{page.pageType === ViewLayoutTypePB.Board && <BoardSvg></BoardSvg>}
{page.pageType === ViewLayoutTypePB.Grid && <GridSvg></GridSvg>}
</div>
<span className={'ml-2 min-w-0 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap'}>{page.title}</span>
</div>
</i>
<span className={'ml-2 min-w-0 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap text-left'}>
{page.title}
</span>
</button>
<div className={'relative flex items-center'}>
<Button size={'box-small-transparent'} onClick={() => onPageOptionsClick()}>
<Details2Svg></Details2Svg>
</Button>
{showPageOptions && (
<NavItemOptionsPopup
onRenameClick={() => startPageRename()}
onDeleteClick={() => deletePage()}
onDuplicateClick={() => duplicatePage()}
onClose={() => closePopup()}
></NavItemOptionsPopup>
)}
</div>
</div>
{showPageOptions && (
<NavItemOptionsPopup
onRenameClick={() => startPageRename()}
onDeleteClick={() => deletePage()}
onDuplicateClick={() => duplicatePage()}
onClose={() => closePopup()}
></NavItemOptionsPopup>
)}
{showRenamePopup && (
<RenamePopup
value={page.title}

View File

@ -30,7 +30,7 @@ export const RenamePopup = ({
<div
ref={ref}
className={
'absolute left-[30px] top-[30px] z-10 flex w-[300px] rounded bg-white py-1 px-1.5 shadow-md ' + className
'absolute left-[50px] top-[40px] z-10 flex w-[300px] rounded bg-white py-1 px-1.5 shadow-md ' + className
}
>
<input

View File

@ -1,11 +1,54 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useEffect } from 'react';
import { NavigationPanel } from './NavigationPanel/NavigationPanel';
import { MainPanel } from './MainPanel';
import { useNavigationPanelHooks } from './NavigationPanel/NavigationPanel.hooks';
import { NavigationFloatingPanel } from './NavigationPanel/NavigationFloatingPanel';
import { useWorkspace } from './Workspace.hooks';
import { useAppSelector } from '../../stores/store';
export const Screen = ({ children }: { children: ReactNode }) => {
const currentUser = useAppSelector((state) => state.currentUser);
const { loadWorkspaceItems } = useWorkspace();
useEffect(() => {
void (async () => {
await loadWorkspaceItems();
})();
}, [currentUser.isAuthenticated]);
const {
width,
folders,
pages,
onPageClick,
onCollapseNavigationClick,
onFixNavigationClick,
navigationPanelFixed,
onScreenMouseMove,
slideInFloatingPanel,
setFloatingPanelWidth,
} = useNavigationPanelHooks();
return (
<div className='flex h-screen w-screen bg-white text-black'>
<NavigationPanel></NavigationPanel>
<div onMouseMove={onScreenMouseMove} className='flex h-screen w-screen bg-white text-black'>
{navigationPanelFixed ? (
<NavigationPanel
onCollapseNavigationClick={onCollapseNavigationClick}
width={width}
folders={folders}
pages={pages}
onPageClick={onPageClick}
></NavigationPanel>
) : (
<NavigationFloatingPanel
onFixNavigationClick={onFixNavigationClick}
slideInFloatingPanel={slideInFloatingPanel}
folders={folders}
pages={pages}
onPageClick={onPageClick}
setWidth={setFloatingPanelWidth}
></NavigationFloatingPanel>
)}
<MainPanel>{children}</MainPanel>
</div>
);

View File

@ -0,0 +1,49 @@
import { foldersActions } from '../../stores/reducers/folders/slice';
import { useAppDispatch, useAppSelector } from '../../stores/store';
import { pagesActions } from '../../stores/reducers/pages/slice';
import { workspaceActions } from '../../stores/reducers/workspace/slice';
import { UserBackendService } from '../../stores/effects/user/user_bd_svc';
import { useError } from '../error/Error.hooks';
export const useWorkspace = () => {
const appDispatch = useAppDispatch();
const currentUser = useAppSelector((state) => state.currentUser);
const error = useError();
const userBackendService: UserBackendService = new UserBackendService(currentUser.id || '');
const loadWorkspaceItems = async () => {
try {
const workspaceSettingPB = await userBackendService.getCurrentWorkspace();
const workspace = workspaceSettingPB.workspace;
appDispatch(workspaceActions.updateWorkspace({ id: workspace.id, name: workspace.name }));
appDispatch(foldersActions.clearFolders());
appDispatch(pagesActions.clearPages());
const apps = workspace.apps.items;
for (const app of apps) {
appDispatch(foldersActions.addFolder({ id: app.id, title: app.name }));
const views = app.belongings.items;
for (const view of views) {
appDispatch(pagesActions.addPage({ folderId: app.id, id: view.id, pageType: view.layout, title: view.name }));
}
}
} catch (e1) {
// create workspace for first start
try {
const workspace = await userBackendService.createWorkspace({ name: 'New Workspace', desc: '' });
appDispatch(workspaceActions.updateWorkspace({ id: workspace.id, name: workspace.name }));
appDispatch(foldersActions.clearFolders());
appDispatch(pagesActions.clearPages());
} catch (e2: any) {
error.showError(e2?.message);
}
}
};
return {
loadWorkspaceItems,
};
};

View File

@ -1,7 +1,8 @@
import { UserNotification } from '../../../../../services/backend';
import { NotificationParser, OnNotificationError } from '../../../../../services/backend/notifications/parser';
import { FlowyError, UserNotification } from '../../../../../services/backend';
import { NotificationParser, OnNotificationError } from '../../../../../services/backend/notifications';
import { Result } from 'ts-results';
declare type UserNotificationCallback = (ty: UserNotification, payload: Uint8Array) => void;
declare type UserNotificationCallback = (ty: UserNotification, payload: Result<Uint8Array, FlowyError>) => void;
export class UserNotificationParser extends NotificationParser<UserNotification> {
constructor(params: { id?: string; callback: UserNotificationCallback; onError?: OnNotificationError }) {
@ -15,8 +16,7 @@ export class UserNotificationParser extends NotificationParser<UserNotification>
return UserNotification.Unknown;
}
},
params.id,
params.onError
params.id
);
}
}

View File

@ -1,9 +1,10 @@
import { UserNotification, UserProfilePB } from '../../../../../services/backend';
import { FlowyError, UserNotification, UserProfilePB } from '../../../../../services/backend';
import { AFNotificationObserver, OnNotificationError } from '../../../../../services/backend/notifications';
import { UserNotificationParser } from './parser';
import { Ok, Result } from 'ts-results';
declare type OnUserProfileUpdate = (userProfile: UserProfilePB) => void;
declare type OnUserSignIn = (userProfile: UserProfilePB) => void;
declare type OnUserProfileUpdate = (result: Result<UserProfilePB, FlowyError>) => void;
declare type OnUserSignIn = (result: Result<UserProfilePB, FlowyError>) => void;
export class UserNotificationListener extends AFNotificationObserver<UserNotification> {
onProfileUpdate?: OnUserProfileUpdate;
@ -16,13 +17,21 @@ export class UserNotificationListener extends AFNotificationObserver<UserNotific
onError?: OnNotificationError;
}) {
const parser = new UserNotificationParser({
callback: (notification, payload) => {
callback: (notification, result) => {
switch (notification) {
case UserNotification.DidUpdateUserProfile:
this.onProfileUpdate?.(UserProfilePB.deserializeBinary(payload));
if (result.ok) {
this.onProfileUpdate?.(Ok(UserProfilePB.deserializeBinary(result.val)));
} else {
this.onProfileUpdate?.(result);
}
break;
case UserNotification.DidUserSignIn:
this.onUserSignIn?.(UserProfilePB.deserializeBinary(payload));
if (result.ok) {
this.onUserSignIn?.(Ok(UserProfilePB.deserializeBinary(result.val)));
} else {
this.onUserSignIn?.(result);
}
break;
default:
break;

View File

@ -1,4 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { None, Option, Some } from 'ts-results';
export class CellCacheKey {
constructor(public readonly fieldId: string, public readonly rowId: string) {}
}
@ -27,19 +29,19 @@ export class CellCache {
inner.set(key.rowId, value);
};
get<T>(key: CellCacheKey): T | null {
get<T>(key: CellCacheKey): Option<T> {
const inner = this._cellDataByFieldId.get(key.fieldId);
if (inner === undefined) {
return null;
return None;
} else {
const value = inner.get(key.rowId);
if (typeof value === typeof undefined || typeof value === typeof null) {
return null;
return None;
}
if (value satisfies T) {
return value as T;
return Some(value as T);
}
return null;
return None;
}
}
}

View File

@ -1,23 +1,23 @@
import { CellIdentifier } from './backend_service';
import { CellCache, CellCacheKey } from './cache';
import { FieldController } from '../field/controller';
import { CellIdentifier } from './cell_bd_svc';
import { CellCache, CellCacheKey } from './cell_cache';
import { FieldController } from '../field/field_controller';
import { CellDataLoader } from './data_parser';
import { CellDataPersistence } from './data_persistence';
import { FieldBackendService, TypeOptionParser } from '../field/backend_service';
import { FieldBackendService, TypeOptionParser } from '../field/field_bd_svc';
import { ChangeNotifier } from '../../../../utils/change_notifier';
import { CellObserver } from './cell_observer';
import { Log } from '../../../../utils/log';
import { Err, Ok } from 'ts-results';
import { Err, None, Ok, Option, Some } from 'ts-results';
export abstract class CellFieldNotifier {
abstract subscribeOnFieldChanged(callback: () => void): void;
}
export class CellController<T, D> {
_fieldBackendService: FieldBackendService;
_cellDataNotifier: CellDataNotifier<T | null>;
_cellObserver: CellObserver;
_cacheKey: CellCacheKey;
private _fieldBackendService: FieldBackendService;
private _cellDataNotifier: CellDataNotifier<Option<T>>;
private _cellObserver: CellObserver;
private _cacheKey: CellCacheKey;
constructor(
public readonly cellIdentifier: CellIdentifier,
@ -27,15 +27,9 @@ export class CellController<T, D> {
private readonly cellDataPersistence: CellDataPersistence<D>
) {
this._fieldBackendService = new FieldBackendService(cellIdentifier.viewId, cellIdentifier.fieldId);
this._cacheKey = new CellCacheKey(cellIdentifier.rowId, cellIdentifier.fieldId);
this._cellDataNotifier = new CellDataNotifier(cellCache.get(this._cacheKey));
this._cellDataNotifier = new CellDataNotifier(cellCache.get<T>(this._cacheKey));
this._cellObserver = new CellObserver(cellIdentifier.rowId, cellIdentifier.fieldId);
}
subscribeChanged = (callbacks: { onCellChanged: (value: T | null) => void; onFieldChanged?: () => void }) => {
this._cellObserver.subscribe({
/// 1.Listen on user edit event and load the new cell data if needed.
/// For example:
@ -46,10 +40,11 @@ export class CellController<T, D> {
await this._loadCellData();
},
});
}
subscribeChanged = (callbacks: { onCellChanged: (value: Option<T>) => void; onFieldChanged?: () => void }) => {
/// 2.Listen on the field event and load the cell data if needed.
this.fieldNotifier.subscribeOnFieldChanged(async () => {
//
callbacks.onFieldChanged?.();
/// reloadOnFieldChanged should be true if you need to load the data when the corresponding field is changed
@ -61,7 +56,9 @@ export class CellController<T, D> {
});
this._cellDataNotifier.observer.subscribe((cellData) => {
callbacks.onCellChanged(cellData);
if (cellData !== null) {
callbacks.onCellChanged(cellData);
}
});
};
@ -81,14 +78,26 @@ export class CellController<T, D> {
}
};
_loadCellData = () => {
/// Return the cell data if it exists in the cache
/// If the cell data is not exist, it will load the cell
/// data from the backend and then the [onCellChanged] will
/// get called
getCellData = (): Option<T> => {
const cellData = this.cellCache.get<T>(this._cacheKey);
if (cellData.none) {
void this._loadCellData();
}
return cellData;
};
private _loadCellData = () => {
return this.cellDataLoader.loadData().then((result) => {
if (result.ok && result.val !== undefined) {
this.cellCache.insert(this._cacheKey, result.val);
this._cellDataNotifier.cellData = result.val;
this._cellDataNotifier.cellData = Some(result.val);
} else {
this.cellCache.remove(this._cacheKey);
this._cellDataNotifier.cellData = null;
this._cellDataNotifier.cellData = None;
}
});
};
@ -98,6 +107,7 @@ export class CellFieldNotifierImpl extends CellFieldNotifier {
constructor(private readonly fieldController: FieldController) {
super();
}
subscribeOnFieldChanged(callback: () => void): void {
this.fieldController.subscribeOnFieldsChanged(callback);
}
@ -105,6 +115,7 @@ export class CellFieldNotifierImpl extends CellFieldNotifier {
class CellDataNotifier<T> extends ChangeNotifier<T | null> {
_cellData: T | null;
constructor(cellData: T) {
super();
this._cellData = cellData;

View File

@ -1,34 +1,37 @@
import { Err, Ok, Result } from 'ts-results';
import { Ok, Result } from 'ts-results';
import { ChangeNotifier } from '../../../../utils/change_notifier';
import { DatabaseNotificationObserver } from '../notifications/observer';
import { FlowyError } from '../../../../../services/backend/models/flowy-error';
import { DatabaseNotification } from '../../../../../services/backend';
import { DatabaseNotification, FlowyError } from '../../../../../services/backend';
type UpdateCellNotifiedValue = Result<void, FlowyError>;
export type CellListenerCallback = (value: UpdateCellNotifiedValue) => void;
export type CellChangedCallback = (value: UpdateCellNotifiedValue) => void;
export class CellObserver {
_notifier?: ChangeNotifier<UpdateCellNotifiedValue>;
_listener?: DatabaseNotificationObserver;
private _notifier?: ChangeNotifier<UpdateCellNotifiedValue>;
private _listener?: DatabaseNotificationObserver;
constructor(public readonly rowId: string, public readonly fieldId: string) {}
subscribe = (callbacks: { onCellChanged: CellListenerCallback }) => {
subscribe = (callbacks: { onCellChanged: CellChangedCallback }) => {
this._notifier = new ChangeNotifier();
this._notifier?.observer.subscribe(callbacks.onCellChanged);
this._listener = new DatabaseNotificationObserver({
viewId: this.rowId + ':' + this.fieldId,
parserHandler: (notification) => {
parserHandler: (notification, result) => {
switch (notification) {
case DatabaseNotification.DidUpdateCell:
this._notifier?.notify(Ok.EMPTY);
if (result.ok) {
this._notifier?.notify(Ok.EMPTY);
} else {
this._notifier?.notify(result);
}
return;
default:
break;
}
},
onError: (error) => this._notifier?.notify(Err(error)),
});
return undefined;
};

View File

@ -4,8 +4,8 @@ import {
SelectOptionCellDataPB,
URLCellDataPB,
} from '../../../../../services/backend/models/flowy-database';
import { CellIdentifier } from './backend_service';
import { CellController, CellFieldNotifierImpl } from './controller';
import { CellIdentifier } from './cell_bd_svc';
import { CellController, CellFieldNotifierImpl } from './cell_controller';
import {
CellDataLoader,
DateCellDataParser,
@ -13,9 +13,10 @@ import {
StringCellDataParser,
URLCellDataParser,
} from './data_parser';
import { CellCache } from './cache';
import { FieldController } from '../field/controller';
import { CellCache } from './cell_cache';
import { FieldController } from '../field/field_controller';
import { DateCellDataPersistence, TextCellDataPersistence } from './data_persistence';
export type TextCellController = CellController<string, string>;
export type CheckboxCellController = CellController<string, string>;
@ -24,9 +25,8 @@ export type NumberCellController = CellController<string, string>;
export type SelectOptionCellController = CellController<SelectOptionCellDataPB, string>;
export type ChecklistCellController = CellController<SelectOptionCellDataPB, string>;
export type DateCellController = CellController<DateCellDataPB, CalendarData>;
export class CalendarData {
constructor(public readonly date: Date, public readonly time?: string) {}
}
@ -35,6 +35,7 @@ export type URLCellController = CellController<URLCellDataPB, string>;
export class CellControllerBuilder {
_fieldNotifier: CellFieldNotifierImpl;
constructor(
public readonly cellIdentifier: CellIdentifier,
public readonly cellCache: CellCache,
@ -42,6 +43,8 @@ export class CellControllerBuilder {
) {
this._fieldNotifier = new CellFieldNotifierImpl(this.fieldController);
}
///
build = () => {
switch (this.cellIdentifier.fieldType) {
case FieldType.Checkbox:

View File

@ -1,5 +1,5 @@
import utf8 from 'utf8';
import { CellBackendService, CellIdentifier } from './backend_service';
import { CellBackendService, CellIdentifier } from './cell_bd_svc';
import { DateCellDataPB } from '../../../../../services/backend/models/flowy-database/date_type_option_entities';
import { SelectOptionCellDataPB } from '../../../../../services/backend/models/flowy-database/select_type_option';
import { URLCellDataPB } from '../../../../../services/backend/models/flowy-database/url_type_option_entities';
@ -30,9 +30,11 @@ class CellDataLoader<T> {
};
}
const utf8Decoder = new TextDecoder('utf-8');
class StringCellDataParser extends CellDataParser<string> {
parserData(data: Uint8Array): string {
return utf8.decode(data.toString());
return utf8Decoder.decode(data);
}
}

View File

@ -1,6 +1,6 @@
import { Result } from 'ts-results';
import { FlowyError } from '../../../../../services/backend/models/flowy-error';
import { CellBackendService, CellIdentifier } from './backend_service';
import { CellBackendService, CellIdentifier } from './cell_bd_svc';
import { CalendarData } from './controller_builder';
import { DateChangesetPB } from '../../../../../services/backend/models/flowy-database/date_type_option_entities';
import { CellIdPB } from '../../../../../services/backend/models/flowy-database/cell_entities';
@ -24,6 +24,7 @@ export class DateCellDataPersistence extends CellDataPersistence<CalendarData> {
constructor(public readonly cellIdentifier: CellIdentifier) {
super();
}
save(data: CalendarData): Promise<Result<void, FlowyError>> {
const payload = DateChangesetPB.fromObject({ cell_path: _makeCellPath(this.cellIdentifier) });

View File

@ -0,0 +1,79 @@
import { CellIdentifier } from './cell_bd_svc';
import {
CellIdPB,
CreateSelectOptionPayloadPB,
SelectOptionCellChangesetPB,
SelectOptionChangesetPB,
SelectOptionPB,
} from '../../../../../services/backend';
import {
DatabaseEventCreateSelectOption,
DatabaseEventGetSelectOptionCellData,
DatabaseEventUpdateSelectOption,
DatabaseEventUpdateSelectOptionCell,
} from '../../../../../services/backend/events/flowy-database';
export class SelectOptionBackendService {
constructor(public readonly cellIdentifier: CellIdentifier) {}
createOption = async (params: { name: string; isSelect?: boolean }) => {
const payload = CreateSelectOptionPayloadPB.fromObject({
option_name: params.name,
view_id: this.cellIdentifier.viewId,
field_id: this.cellIdentifier.fieldId,
});
const result = await DatabaseEventCreateSelectOption(payload);
if (result.ok) {
return this._insertOption(result.val, params.isSelect || true);
} else {
return result;
}
};
updateOption = (option: SelectOptionPB) => {
const payload = SelectOptionChangesetPB.fromObject({ cell_identifier: this._cellIdentifier() });
payload.update_options.push(option);
return DatabaseEventUpdateSelectOption(payload);
};
deleteOption = (options: SelectOptionPB[]) => {
const payload = SelectOptionChangesetPB.fromObject({ cell_identifier: this._cellIdentifier() });
payload.delete_options.push(...options);
return DatabaseEventUpdateSelectOption(payload);
};
getOptionCellData = () => {
return DatabaseEventGetSelectOptionCellData(this._cellIdentifier());
};
selectOption = (optionIds: string[]) => {
const payload = SelectOptionCellChangesetPB.fromObject({ cell_identifier: this._cellIdentifier() });
payload.insert_option_ids.push(...optionIds);
return DatabaseEventUpdateSelectOptionCell(payload);
};
unselectOption = (optionIds: string[]) => {
const payload = SelectOptionCellChangesetPB.fromObject({ cell_identifier: this._cellIdentifier() });
payload.delete_option_ids.push(...optionIds);
return DatabaseEventUpdateSelectOptionCell(payload);
};
private _insertOption = (option: SelectOptionPB, isSelect: boolean) => {
const payload = SelectOptionChangesetPB.fromObject({ cell_identifier: this._cellIdentifier() });
if (isSelect) {
payload.insert_options.push(option);
} else {
payload.update_options.push(option);
}
return DatabaseEventUpdateSelectOption(payload);
};
private _cellIdentifier = () => {
return CellIdPB.fromObject({
view_id: this.cellIdentifier.viewId,
field_id: this.cellIdentifier.fieldId,
row_id: this.cellIdentifier.rowId,
});
};
}

View File

@ -1,51 +0,0 @@
import { DatabaseBackendService } from './backend_service';
import { FieldController, FieldInfo } from './field/controller';
import { DatabaseViewCache } from './view/cache';
import { DatabasePB } from '../../../../services/backend/models/flowy-database/grid_entities';
import { RowChangedReason, RowInfo } from './row/cache';
import { Err } from 'ts-results';
export type SubscribeCallback = {
onViewChanged: (data: DatabasePB) => void;
onRowsChanged: (rowInfos: RowInfo[], reason: RowChangedReason) => void;
onFieldsChanged: (fieldInfos: FieldInfo[]) => void;
};
export class DatabaseController {
_backendService: DatabaseBackendService;
_fieldController: FieldController;
_databaseViewCache: DatabaseViewCache;
_callback?: SubscribeCallback;
constructor(public readonly viewId: string) {
this._backendService = new DatabaseBackendService(viewId);
this._fieldController = new FieldController(viewId);
this._databaseViewCache = new DatabaseViewCache(viewId, this._fieldController);
}
subscribe = (callbacks: SubscribeCallback) => {
this._callback = callbacks;
this._fieldController.subscribeOnFieldsChanged(callbacks.onFieldsChanged);
};
open = async () => {
const result = await this._backendService.openDatabase();
if (result.ok) {
const database: DatabasePB = result.val;
this._callback?.onViewChanged(database);
this._databaseViewCache.initializeWithRows(database.rows);
return await this._fieldController.loadFields(database.fields);
} else {
return Err(result.val);
}
};
createRow = async () => {
return this._backendService.createRow();
};
dispose = async () => {
await this._backendService.closeDatabase();
await this._fieldController.dispose();
};
}

View File

@ -2,15 +2,15 @@ import {
DatabaseEventCreateRow,
DatabaseEventGetDatabase,
DatabaseEventGetFields,
} from '../../../../services/backend/events/flowy-database/event';
import { DatabaseViewIdPB } from '../../../../services/backend/models/flowy-database';
import { CreateRowPayloadPB } from '../../../../services/backend/models/flowy-database/row_entities';
} from '../../../../services/backend/events/flowy-database';
import {
GetFieldPayloadPB,
RepeatedFieldIdPB,
FieldIdPB,
} from '../../../../services/backend/models/flowy-database/field_entities';
import { ViewIdPB } from '../../../../services/backend/models/flowy-folder/view';
DatabaseViewIdPB,
CreateRowPayloadPB,
ViewIdPB,
} from '../../../../services/backend';
import { FolderEventCloseView } from '../../../../services/backend/events/flowy-folder';
export class DatabaseBackendService {

View File

@ -0,0 +1,56 @@
import { DatabaseBackendService } from './database_bd_svc';
import { FieldController, FieldInfo } from './field/field_controller';
import { DatabaseViewCache } from './view/database_view_cache';
import { DatabasePB } from '../../../../services/backend/models/flowy-database/grid_entities';
import { RowChangedReason, RowInfo } from './row/row_cache';
import { Err, Ok } from 'ts-results';
export type SubscribeCallback = {
onViewChanged?: (data: DatabasePB) => void;
onRowsChanged?: (rowInfos: readonly RowInfo[], reason: RowChangedReason) => void;
onFieldsChanged?: (fieldInfos: readonly FieldInfo[]) => void;
};
export class DatabaseController {
private _backendService: DatabaseBackendService;
fieldController: FieldController;
databaseViewCache: DatabaseViewCache;
private _callback?: SubscribeCallback;
constructor(public readonly viewId: string) {
this._backendService = new DatabaseBackendService(viewId);
this.fieldController = new FieldController(viewId);
this.databaseViewCache = new DatabaseViewCache(viewId, this.fieldController);
}
subscribe = (callbacks: SubscribeCallback) => {
this._callback = callbacks;
this.fieldController.subscribeOnFieldsChanged(callbacks.onFieldsChanged);
this.databaseViewCache.getRowCache().subscribeOnRowsChanged((reason) => {
this._callback?.onRowsChanged?.(this.databaseViewCache.rowInfos, reason);
});
};
open = async () => {
const result = await this._backendService.openDatabase();
if (result.ok) {
const database: DatabasePB = result.val;
this._callback?.onViewChanged?.(database);
await this.fieldController.loadFields(database.fields);
this.databaseViewCache.initializeWithRows(database.rows);
return Ok.EMPTY;
} else {
return Err(result.val);
}
};
createRow = async () => {
return this._backendService.createRow();
};
dispose = async () => {
await this._backendService.closeDatabase();
await this.fieldController.dispose();
await this.databaseViewCache.dispose();
};
}

View File

@ -71,7 +71,6 @@ export class FieldBackendService {
duplicateField = () => {
const payload = DuplicateFieldPayloadPB.fromObject({ view_id: this.viewId, field_id: this.fieldId });
return DatabaseEventDuplicateField(payload);
};

View File

@ -1,13 +1,13 @@
import { Log } from '../../../../utils/log';
import { DatabaseBackendService } from '../backend_service';
import { DatabaseBackendService } from '../database_bd_svc';
import { DatabaseFieldObserver } from './field_observer';
import { FieldIdPB, FieldPB, IndexFieldPB } from '../../../../../services/backend/models/flowy-database/field_entities';
import { ChangeNotifier } from '../../../../utils/change_notifier';
export class FieldController {
_fieldListener: DatabaseFieldObserver;
_backendService: DatabaseBackendService;
_fieldNotifier = new FieldNotifier([]);
private _fieldListener: DatabaseFieldObserver;
private _backendService: DatabaseBackendService;
private _fieldNotifier = new FieldNotifier([]);
constructor(public readonly viewId: string) {
this._backendService = new DatabaseBackendService(viewId);
@ -33,12 +33,14 @@ export class FieldController {
const result = await this._backendService.getFields(fieldIds);
if (result.ok) {
this._fieldNotifier.fieldInfos = result.val.map((field) => new FieldInfo(field));
} else {
Log.error(result.val);
}
};
subscribeOnFieldsChanged = (callback: (fieldInfos: FieldInfo[]) => void) => {
subscribeOnFieldsChanged = (callback?: (fieldInfos: readonly FieldInfo[]) => void) => {
return this._fieldNotifier.observer.subscribe((fieldInfos) => {
callback(fieldInfos);
callback?.(fieldInfos);
});
};

View File

@ -9,27 +9,30 @@ type UpdateFieldNotifiedValue = Result<DatabaseFieldChangesetPB, FlowyError>;
export type DatabaseNotificationCallback = (value: UpdateFieldNotifiedValue) => void;
export class DatabaseFieldObserver {
_notifier?: ChangeNotifier<UpdateFieldNotifiedValue>;
_listener?: DatabaseNotificationObserver;
private _notifier?: ChangeNotifier<UpdateFieldNotifiedValue>;
private _listener?: DatabaseNotificationObserver;
constructor(public readonly databaseId: string) {}
constructor(public readonly viewId: string) {}
subscribe = (callbacks: { onFieldsChanged: DatabaseNotificationCallback }) => {
this._notifier = new ChangeNotifier();
this._notifier?.observer.subscribe(callbacks.onFieldsChanged);
this._listener = new DatabaseNotificationObserver({
viewId: this.databaseId,
parserHandler: (notification, payload) => {
viewId: this.viewId,
parserHandler: (notification, result) => {
switch (notification) {
case DatabaseNotification.DidUpdateFields:
this._notifier?.notify(Ok(DatabaseFieldChangesetPB.deserializeBinary(payload)));
if (result.ok) {
this._notifier?.notify(Ok(DatabaseFieldChangesetPB.deserializeBinary(result.val)));
} else {
this._notifier?.notify(result);
}
return;
default:
break;
}
},
onError: (error) => this._notifier?.notify(Err(error)),
});
return undefined;
};

View File

@ -1,16 +1,16 @@
import { DatabaseNotification } from '../../../../../services/backend/models/flowy-database/notification';
import { OnNotificationError } from '../../../../../services/backend/notifications';
import { AFNotificationObserver } from '../../../../../services/backend/notifications/observer';
import { DatabaseNotification } from '../../../../../services/backend';
import { AFNotificationObserver } from '../../../../../services/backend/notifications';
import { DatabaseNotificationParser } from './parser';
import { FlowyError } from '../../../../../services/backend';
import { Result } from 'ts-results';
export type ParserHandler = (notification: DatabaseNotification, payload: Uint8Array) => void;
export type ParserHandler = (notification: DatabaseNotification, result: Result<Uint8Array, FlowyError>) => void;
export class DatabaseNotificationObserver extends AFNotificationObserver<DatabaseNotification> {
constructor(params: { viewId?: string; parserHandler: ParserHandler; onError?: OnNotificationError }) {
constructor(params: { viewId?: string; parserHandler: ParserHandler }) {
const parser = new DatabaseNotificationParser({
callback: params.parserHandler,
id: params.viewId,
onError: params.onError,
});
super(parser);
}

View File

@ -1,10 +1,11 @@
import { DatabaseNotification } from '../../../../../services/backend';
import { NotificationParser, OnNotificationError } from '../../../../../services/backend/notifications';
import { DatabaseNotification, FlowyError } from '../../../../../services/backend';
import { NotificationParser } from '../../../../../services/backend/notifications';
import { Result } from 'ts-results';
declare type DatabaseNotificationCallback = (ty: DatabaseNotification, payload: Uint8Array) => void;
declare type DatabaseNotificationCallback = (ty: DatabaseNotification, payload: Result<Uint8Array, FlowyError>) => void;
export class DatabaseNotificationParser extends NotificationParser<DatabaseNotification> {
constructor(params: { id?: string; callback: DatabaseNotificationCallback; onError?: OnNotificationError }) {
constructor(params: { id?: string; callback: DatabaseNotificationCallback }) {
super(
params.callback,
(ty) => {
@ -15,8 +16,7 @@ export class DatabaseNotificationParser extends NotificationParser<DatabaseNotif
return DatabaseNotification.Unknown;
}
},
params.id,
params.onError
params.id
);
}
}

View File

@ -1,18 +1,27 @@
import { RowPB, InsertedRowPB, UpdatedRowPB } from '../../../../../services/backend/models/flowy-database/row_entities';
import { ChangeNotifier } from '../../../../utils/change_notifier';
import { FieldInfo } from '../field/controller';
import { CellCache, CellCacheKey } from '../cell/cache';
import {
RowPB,
InsertedRowPB,
UpdatedRowPB,
RowIdPB,
OptionalRowPB,
RowsChangesetPB,
RowsVisibilityChangesetPB,
} from '../../../../../services/backend/models/flowy-database/view_entities';
import { CellIdentifier } from '../cell/backend_service';
import { ReorderSingleRowPB } from '../../../../../services/backend/models/flowy-database/sort_entities';
ReorderSingleRowPB,
} from '../../../../../services/backend';
import { ChangeNotifier } from '../../../../utils/change_notifier';
import { FieldInfo } from '../field/field_controller';
import { CellCache, CellCacheKey } from '../cell/cell_cache';
import { CellIdentifier } from '../cell/cell_bd_svc';
import { DatabaseEventGetRow } from '../../../../../services/backend/events/flowy-database';
import { None, Option, Some } from 'ts-results';
import { Log } from '../../../../utils/log';
export type CellByFieldId = Map<string, CellIdentifier>;
export class RowCache {
_rowList: RowList;
_cellCache: CellCache;
_notifier: RowChangeNotifier;
private readonly _rowList: RowList;
private readonly _cellCache: CellCache;
private readonly _notifier: RowChangeNotifier;
constructor(public readonly viewId: string, private readonly getFieldInfos: () => readonly FieldInfo[]) {
this._rowList = new RowList();
@ -24,6 +33,26 @@ export class RowCache {
return this._rowList.rows;
}
getCellCache = () => {
return this._cellCache;
};
loadCells = async (rowId: string): Promise<CellByFieldId> => {
const opRow = this._rowList.getRow(rowId);
if (opRow.some) {
return this._toCellMap(opRow.val.row.id, this.getFieldInfos());
} else {
const rowResult = await this._loadRow(rowId);
if (rowResult.ok) {
this._refreshRow(rowResult.val);
return this._toCellMap(rowId, this.getFieldInfos());
} else {
Log.error(rowResult.val);
return new Map();
}
}
};
subscribeOnRowsChanged = (callback: (reason: RowChangedReason, cellMap?: Map<string, CellIdentifier>) => void) => {
return this._notifier.observer.subscribe((change) => {
if (change.rowId !== undefined) {
@ -47,6 +76,7 @@ export class RowCache {
rows.forEach((rowPB) => {
this._rowList.push(this._toRowInfo(rowPB));
});
this._notifier.withChange(RowChangedReason.ReorderRows);
};
applyRowsChanged = (changeset: RowsChangesetPB) => {
@ -73,7 +103,28 @@ export class RowCache {
}
};
_deleteRows = (rowIds: string[]) => {
private _refreshRow = (opRow: OptionalRowPB) => {
if (!opRow.has_row) {
return;
}
const updatedRow = opRow.row;
const option = this._rowList.getRowWithIndex(updatedRow.id);
if (option.some) {
const { rowInfo, index } = option.val;
this._rowList.remove(rowInfo.row.id);
this._rowList.insert(index, rowInfo.copyWith({ row: updatedRow }));
} else {
const newRowInfo = new RowInfo(this.viewId, this.getFieldInfos(), updatedRow);
this._rowList.push(newRowInfo);
}
};
private _loadRow = (rowId: string) => {
const payload = RowIdPB.fromObject({ view_id: this.viewId, row_id: rowId });
return DatabaseEventGetRow(payload);
};
private _deleteRows = (rowIds: string[]) => {
rowIds.forEach((rowId) => {
const deletedRow = this._rowList.remove(rowId);
if (deletedRow !== undefined) {
@ -82,7 +133,7 @@ export class RowCache {
});
};
_insertRows = (rows: InsertedRowPB[]) => {
private _insertRows = (rows: InsertedRowPB[]) => {
rows.forEach((insertedRow) => {
const rowInfo = this._toRowInfo(insertedRow.row);
const insertedIndex = this._rowList.insert(insertedRow.index, rowInfo);
@ -92,7 +143,7 @@ export class RowCache {
});
};
_updateRows = (updatedRows: UpdatedRowPB[]) => {
private _updateRows = (updatedRows: UpdatedRowPB[]) => {
if (updatedRows.length === 0) {
return;
}
@ -113,7 +164,7 @@ export class RowCache {
});
};
_hideRows = (rowIds: string[]) => {
private _hideRows = (rowIds: string[]) => {
rowIds.forEach((rowId) => {
const deletedRow = this._rowList.remove(rowId);
if (deletedRow !== undefined) {
@ -122,7 +173,7 @@ export class RowCache {
});
};
_displayRows = (insertedRows: InsertedRowPB[]) => {
private _displayRows = (insertedRows: InsertedRowPB[]) => {
insertedRows.forEach((insertedRow) => {
const insertedIndex = this._rowList.insert(insertedRow.index, this._toRowInfo(insertedRow.row));
@ -136,11 +187,11 @@ export class RowCache {
this._notifier.dispose();
};
_toRowInfo = (rowPB: RowPB) => {
private _toRowInfo = (rowPB: RowPB) => {
return new RowInfo(this.viewId, this.getFieldInfos(), rowPB);
};
_toCellMap = (rowId: string, fieldInfos: readonly FieldInfo[]): Map<string, CellIdentifier> => {
private _toCellMap = (rowId: string, fieldInfos: readonly FieldInfo[]): CellByFieldId => {
const cellIdentifierByFieldId: Map<string, CellIdentifier> = new Map();
fieldInfos.forEach((fieldInfo) => {
@ -160,17 +211,22 @@ class RowList {
return this._rowInfos;
}
getRow = (rowId: string) => {
return this._rowInfoByRowId.get(rowId);
getRow = (rowId: string): Option<RowInfo> => {
const rowInfo = this._rowInfoByRowId.get(rowId);
if (rowInfo === undefined) {
return None;
} else {
return Some(rowInfo);
}
};
getRowWithIndex = (rowId: string): { rowInfo: RowInfo; index: number } | undefined => {
getRowWithIndex = (rowId: string): Option<{ rowInfo: RowInfo; index: number }> => {
const rowInfo = this._rowInfoByRowId.get(rowId);
if (rowInfo !== undefined) {
const index = this._rowInfos.indexOf(rowInfo, 0);
return { rowInfo: rowInfo, index: index };
return Some({ rowInfo: rowInfo, index: index });
}
return undefined;
return None;
};
indexOfRow = (rowId: string): number => {
@ -194,27 +250,29 @@ class RowList {
remove = (rowId: string): DeletedRow | undefined => {
const result = this.getRowWithIndex(rowId);
if (result !== undefined) {
this._rowInfoByRowId.delete(result.rowInfo.row.id);
this._rowInfos.splice(result.index, 1);
return new DeletedRow(result.index, result.rowInfo);
if (result.some) {
const { rowInfo, index } = result.val;
this._rowInfoByRowId.delete(rowInfo.row.id);
this._rowInfos.splice(index, 1);
return new DeletedRow(index, rowInfo);
} else {
return undefined;
}
};
insert = (index: number, newRowInfo: RowInfo): InsertedRow | undefined => {
insert = (insertIndex: number, newRowInfo: RowInfo): InsertedRow | undefined => {
const rowId = newRowInfo.row.id;
// Calibrate where to insert
let insertedIndex = index;
let insertedIndex = insertIndex;
if (this._rowInfos.length <= insertedIndex) {
insertedIndex = this._rowInfos.length;
}
const result = this.getRowWithIndex(rowId);
if (result !== undefined) {
if (result.some) {
const { index } = result.val;
// remove the old row info
this._rowInfos.splice(result.index, 1);
this._rowInfos.splice(index, 1);
// insert the new row info to the insertedIndex
this._rowInfos.splice(insertedIndex, 0, newRowInfo);
this._rowInfoByRowId.set(rowId, newRowInfo);
@ -268,10 +326,14 @@ class RowList {
export class RowInfo {
constructor(
public readonly databaseId: string,
public readonly viewId: string,
public readonly fieldInfos: readonly FieldInfo[],
public readonly row: RowPB
) {}
copyWith = (params: { row?: RowPB }) => {
return new RowInfo(this.viewId, this.fieldInfos, params.row || this.row);
};
}
export class DeletedRow {

View File

@ -0,0 +1,16 @@
import { CellByFieldId, RowCache, RowInfo } from './row_cache';
import { FieldController } from '../field/field_controller';
export class RowController {
constructor(
public readonly rowInfo: RowInfo,
public readonly fieldController: FieldController,
private readonly cache: RowCache
) {
//
}
loadCells = async (): Promise<CellByFieldId> => {
return this.cache.loadCells(this.rowInfo.row.id);
};
}

View File

@ -1,15 +1,22 @@
import { DatabaseViewRowsObserver } from './row_observer';
import { RowCache, RowChangedReason } from '../row/cache';
import { FieldController } from '../field/controller';
import { DatabaseViewRowsObserver } from './view_row_observer';
import { RowCache, RowChangedReason, RowInfo } from '../row/row_cache';
import { FieldController } from '../field/field_controller';
import { RowPB } from '../../../../../services/backend/models/flowy-database/row_entities';
import { Subscription } from 'rxjs';
export class DatabaseViewCache {
_rowsObserver: DatabaseViewRowsObserver;
_rowCache: RowCache;
private readonly _rowsObserver: DatabaseViewRowsObserver;
private readonly _rowCache: RowCache;
private readonly _fieldSubscription?: Subscription;
constructor(public readonly viewId: string, fieldController: FieldController) {
this._rowsObserver = new DatabaseViewRowsObserver(viewId);
this._rowCache = new RowCache(viewId, () => fieldController.fieldInfos);
this._fieldSubscription = fieldController.subscribeOnFieldsChanged((fieldInfos) => {
fieldInfos.forEach((fieldInfo) => {
this._rowCache.onFieldUpdated(fieldInfo);
});
});
this._listenOnRowsChanged();
}
@ -17,13 +24,16 @@ export class DatabaseViewCache {
this._rowCache.initializeRows(rows);
};
subscribeOnRowsChanged = (onRowsChanged: (reason: RowChangedReason) => void) => {
return this._rowCache.subscribeOnRowsChanged((reason) => {
onRowsChanged(reason);
});
get rowInfos(): readonly RowInfo[] {
return this._rowCache.rows;
}
getRowCache = () => {
return this._rowCache;
};
dispose = async () => {
this._fieldSubscription?.unsubscribe();
await this._rowsObserver.unsubscribe();
await this._rowCache.dispose();
};

View File

@ -1,14 +1,12 @@
import { Ok, Result } from 'ts-results';
import {
DatabaseNotification,
FlowyError,
ReorderAllRowsPB,
ReorderSingleRowPB,
} from '../../../../../services/backend/events/flowy-database';
import {
RowsChangesetPB,
RowsVisibilityChangesetPB,
} from '../../../../../services/backend/models/flowy-database/view_entities';
import { FlowyError } from '../../../../../services/backend/models/flowy-error/errors';
} from '../../../../../services/backend';
import { ChangeNotifier } from '../../../../utils/change_notifier';
import { DatabaseNotificationObserver } from '../notifications/observer';
@ -18,12 +16,13 @@ export type ReorderRowsNotifyValue = Result<string[], FlowyError>;
export type ReorderSingleRowNotifyValue = Result<ReorderSingleRowPB, FlowyError>;
export class DatabaseViewRowsObserver {
_rowsVisibilityNotifier = new ChangeNotifier<RowsVisibilityNotifyValue>();
_rowsNotifier = new ChangeNotifier<RowsNotifyValue>();
_reorderRowsNotifier = new ChangeNotifier<ReorderRowsNotifyValue>();
_reorderSingleRowNotifier = new ChangeNotifier<ReorderSingleRowNotifyValue>();
private _rowsVisibilityNotifier = new ChangeNotifier<RowsVisibilityNotifyValue>();
private _rowsNotifier = new ChangeNotifier<RowsNotifyValue>();
private _reorderRowsNotifier = new ChangeNotifier<ReorderRowsNotifyValue>();
private _reorderSingleRowNotifier = new ChangeNotifier<ReorderSingleRowNotifyValue>();
private _listener?: DatabaseNotificationObserver;
_listener?: DatabaseNotificationObserver;
constructor(public readonly viewId: string) {}
subscribe = (callbacks: {
@ -40,19 +39,35 @@ export class DatabaseViewRowsObserver {
this._listener = new DatabaseNotificationObserver({
viewId: this.viewId,
parserHandler: (notification, payload) => {
parserHandler: (notification, result) => {
switch (notification) {
case DatabaseNotification.DidUpdateViewRowsVisibility:
this._rowsVisibilityNotifier.notify(Ok(RowsVisibilityChangesetPB.deserializeBinary(payload)));
if (result.ok) {
this._rowsVisibilityNotifier.notify(Ok(RowsVisibilityChangesetPB.deserializeBinary(result.val)));
} else {
this._rowsVisibilityNotifier.notify(result);
}
break;
case DatabaseNotification.DidUpdateViewRows:
this._rowsNotifier.notify(Ok(RowsChangesetPB.deserializeBinary(payload)));
if (result.ok) {
this._rowsNotifier.notify(Ok(RowsChangesetPB.deserializeBinary(result.val)));
} else {
this._rowsNotifier.notify(result);
}
break;
case DatabaseNotification.DidReorderRows:
this._reorderRowsNotifier.notify(Ok(ReorderAllRowsPB.deserializeBinary(payload).row_orders));
if (result.ok) {
this._reorderRowsNotifier.notify(Ok(ReorderAllRowsPB.deserializeBinary(result.val).row_orders));
} else {
this._reorderRowsNotifier.notify(result);
}
break;
case DatabaseNotification.DidReorderSingleRow:
this._reorderSingleRowNotifier.notify(Ok(ReorderSingleRowPB.deserializeBinary(payload)));
if (result.ok) {
this._reorderSingleRowNotifier.notify(Ok(ReorderSingleRowPB.deserializeBinary(result.val)));
} else {
this._reorderSingleRowNotifier.notify(result);
}
break;
default:
break;

View File

@ -5,18 +5,18 @@ import {
FolderEventMoveItem,
FolderEventReadApp,
FolderEventUpdateApp,
ViewDataFormatPB,
ViewLayoutTypePB,
} from '../../../../../services/backend/events/flowy-folder';
import { AppIdPB, UpdateAppPayloadPB } from '../../../../../services/backend/models/flowy-folder/app';
import {
AppIdPB,
UpdateAppPayloadPB,
CreateViewPayloadPB,
RepeatedViewIdPB,
ViewPB,
MoveFolderItemPayloadPB,
MoveFolderItemType,
} from '../../../../../services/backend/models/flowy-folder/view';
import { FlowyError } from '../../../../../services/backend/models/flowy-error/errors';
FlowyError,
} from '../../../../../services/backend';
import { None, Result, Some } from 'ts-results';
export class AppBackendService {
@ -27,12 +27,11 @@ export class AppBackendService {
return FolderEventReadApp(payload);
};
createView = (params: {
createView = async (params: {
name: string;
desc?: string;
dataFormatType: ViewDataFormatPB;
layoutType: ViewLayoutTypePB;
/// The initial data should be the JSON of the doucment
/// The initial data should be the JSON of the document
/// For example: {"document":{"type":"editor","children":[]}}
initialData?: string;
}) => {
@ -45,7 +44,13 @@ export class AppBackendService {
initial_data: encoder.encode(params.initialData || ''),
});
return FolderEventCreateView(payload);
const result = await FolderEventCreateView(payload);
if (result.ok) {
return result.val;
} else {
throw new Error(result.val.msg);
}
};
getAllViews = (): Promise<Result<ViewPB[], FlowyError>> => {
@ -69,14 +74,20 @@ export class AppBackendService {
}
};
update = (params: { name: string }) => {
update = async (params: { name: string }) => {
const payload = UpdateAppPayloadPB.fromObject({ app_id: this.appId, name: params.name });
return FolderEventUpdateApp(payload);
const result = await FolderEventUpdateApp(payload);
if (!result.ok) {
throw new Error(result.val.msg);
}
};
delete = () => {
delete = async () => {
const payload = AppIdPB.fromObject({ value: this.appId });
return FolderEventDeleteApp(payload);
const result = await FolderEventDeleteApp(payload);
if (!result.ok) {
throw new Error(result.val.msg);
}
};
deleteView = (viewId: string) => {

View File

@ -1,6 +1,5 @@
import { Ok, Result } from 'ts-results';
import { AppPB, FolderNotification } from '../../../../../services/backend';
import { FlowyError } from '../../../../../services/backend/models/flowy-error';
import { AppPB, FlowyError, FolderNotification } from '../../../../../services/backend';
import { ChangeNotifier } from '../../../../utils/change_notifier';
import { FolderNotificationObserver } from '../notifications/observer';
@ -18,10 +17,14 @@ export class WorkspaceObserver {
this._listener = new FolderNotificationObserver({
viewId: this.appId,
parserHandler: (notification, payload) => {
parserHandler: (notification, result) => {
switch (notification) {
case FolderNotification.DidUpdateWorkspaceApps:
this._appNotifier?.notify(Ok(AppPB.deserializeBinary(payload)));
if (result.ok) {
this._appNotifier?.notify(Ok(AppPB.deserializeBinary(result.val)));
} else {
this._appNotifier?.notify(result);
}
break;
default:
break;

View File

@ -1,16 +1,16 @@
import { OnNotificationError } from '../../../../../services/backend/notifications';
import { AFNotificationObserver } from '../../../../../services/backend/notifications/observer';
import { AFNotificationObserver } from '../../../../../services/backend/notifications';
import { FolderNotificationParser } from './parser';
import { FolderNotification } from '../../../../../services/backend/models/flowy-folder/notification';
import { FlowyError, FolderNotification } from '../../../../../services/backend';
import { Result } from 'ts-results';
export type ParserHandler = (notification: FolderNotification, payload: Uint8Array) => void;
export type ParserHandler = (notification: FolderNotification, payload: Result<Uint8Array, FlowyError>) => void;
export class FolderNotificationObserver extends AFNotificationObserver<FolderNotification> {
constructor(params: { viewId?: string; parserHandler: ParserHandler; onError?: OnNotificationError }) {
const parser = new FolderNotificationParser({
callback: params.parserHandler,
id: params.viewId,
onError: params.onError,
});
super(parser);
}

View File

@ -1,7 +1,8 @@
import { NotificationParser, OnNotificationError } from '../../../../../services/backend/notifications';
import { FolderNotification } from '../../../../../services/backend/models/flowy-folder/notification';
import { FlowyError, FolderNotification } from '../../../../../services/backend';
import { Result } from 'ts-results';
declare type FolderNotificationCallback = (ty: FolderNotification, payload: Uint8Array) => void;
declare type FolderNotificationCallback = (ty: FolderNotification, payload: Result<Uint8Array, FlowyError>) => void;
export class FolderNotificationParser extends NotificationParser<FolderNotification> {
constructor(params: { id?: string; callback: FolderNotificationCallback; onError?: OnNotificationError }) {
@ -15,8 +16,7 @@ export class FolderNotificationParser extends NotificationParser<FolderNotificat
return FolderNotification.Unknown;
}
},
params.id,
params.onError
params.id
);
}
}

View File

@ -10,11 +10,11 @@ type RestoreViewNotifyValue = Result<ViewPB, FlowyError>;
type MoveToTrashViewNotifyValue = Result<DeletedViewPB, FlowyError>;
export class ViewObserver {
_deleteViewNotifier = new ChangeNotifier<DeleteViewNotifyValue>();
_updateViewNotifier = new ChangeNotifier<UpdateViewNotifyValue>();
_restoreViewNotifier = new ChangeNotifier<RestoreViewNotifyValue>();
_moveToTashNotifier = new ChangeNotifier<MoveToTrashViewNotifyValue>();
_listener?: FolderNotificationObserver;
private _deleteViewNotifier = new ChangeNotifier<DeleteViewNotifyValue>();
private _updateViewNotifier = new ChangeNotifier<UpdateViewNotifyValue>();
private _restoreViewNotifier = new ChangeNotifier<RestoreViewNotifyValue>();
private _moveToTashNotifier = new ChangeNotifier<MoveToTrashViewNotifyValue>();
private _listener?: FolderNotificationObserver;
constructor(public readonly viewId: string) {}
@ -42,19 +42,35 @@ export class ViewObserver {
this._listener = new FolderNotificationObserver({
viewId: this.viewId,
parserHandler: (notification, payload) => {
parserHandler: (notification, result) => {
switch (notification) {
case FolderNotification.DidUpdateView:
this._updateViewNotifier.notify(Ok(ViewPB.deserializeBinary(payload)));
if (result.ok) {
this._updateViewNotifier.notify(Ok(ViewPB.deserializeBinary(result.val)));
} else {
this._updateViewNotifier.notify(result);
}
break;
case FolderNotification.DidDeleteView:
this._deleteViewNotifier.notify(Ok(ViewPB.deserializeBinary(payload)));
if (result.ok) {
this._deleteViewNotifier.notify(Ok(ViewPB.deserializeBinary(result.val)));
} else {
this._deleteViewNotifier.notify(result);
}
break;
case FolderNotification.DidRestoreView:
this._restoreViewNotifier.notify(Ok(ViewPB.deserializeBinary(payload)));
if (result.ok) {
this._restoreViewNotifier.notify(Ok(ViewPB.deserializeBinary(result.val)));
} else {
this._restoreViewNotifier.notify(result);
}
break;
case FolderNotification.DidMoveViewToTrash:
this._moveToTashNotifier.notify(Ok(DeletedViewPB.deserializeBinary(payload)));
if (result.ok) {
this._moveToTashNotifier.notify(Ok(DeletedViewPB.deserializeBinary(result.val)));
} else {
this._moveToTashNotifier.notify(result);
}
break;
default:
break;

View File

@ -5,23 +5,25 @@ import {
FolderEventReadWorkspaceApps,
FolderEventReadWorkspaces,
} from '../../../../../services/backend/events/flowy-folder';
import { CreateAppPayloadPB } from '../../../../../services/backend/models/flowy-folder/app';
import { WorkspaceIdPB } from '../../../../../services/backend/models/flowy-folder/workspace';
import { CreateAppPayloadPB, WorkspaceIdPB, FlowyError, MoveFolderItemPayloadPB } from '../../../../../services/backend';
import assert from 'assert';
import { FlowyError } from '../../../../../services/backend/models/flowy-error/errors';
import { MoveFolderItemPayloadPB } from '../../../../../services/backend/models/flowy-folder/view';
export class WorkspaceBackendService {
constructor(public readonly workspaceId: string) {}
createApp = (params: { name: string; desc?: string }) => {
createApp = async (params: { name: string; desc?: string }) => {
const payload = CreateAppPayloadPB.fromObject({
workspace_id: this.workspaceId,
name: params.name,
desc: params.desc || '',
});
return FolderEventCreateApp(payload);
const result = await FolderEventCreateApp(payload);
if (result.ok) {
return result.val;
} else {
throw new Error(result.val.msg);
}
};
getWorkspace = () => {

View File

@ -1,6 +1,5 @@
import { Ok, Result } from 'ts-results';
import { AppPB, FolderNotification, RepeatedAppPB, WorkspacePB } from '../../../../../services/backend';
import { FlowyError } from '../../../../../services/backend/models/flowy-error';
import { AppPB, FolderNotification, RepeatedAppPB, WorkspacePB, FlowyError } from '../../../../../services/backend';
import { ChangeNotifier } from '../../../../utils/change_notifier';
import { FolderNotificationObserver } from '../notifications/observer';
@ -10,9 +9,9 @@ export type WorkspaceNotifyValue = Result<WorkspacePB, FlowyError>;
export type WorkspaceNotifyCallback = (value: WorkspaceNotifyValue) => void;
export class WorkspaceObserver {
_appListNotifier = new ChangeNotifier<AppListNotifyValue>();
_workspaceNotifier = new ChangeNotifier<WorkspaceNotifyValue>();
_listener?: FolderNotificationObserver;
private _appListNotifier = new ChangeNotifier<AppListNotifyValue>();
private _workspaceNotifier = new ChangeNotifier<WorkspaceNotifyValue>();
private _listener?: FolderNotificationObserver;
constructor(public readonly workspaceId: string) {}
@ -22,13 +21,21 @@ export class WorkspaceObserver {
this._listener = new FolderNotificationObserver({
viewId: this.workspaceId,
parserHandler: (notification, payload) => {
parserHandler: (notification, result) => {
switch (notification) {
case FolderNotification.DidUpdateWorkspace:
this._workspaceNotifier?.notify(Ok(WorkspacePB.deserializeBinary(payload)));
if (result.ok) {
this._workspaceNotifier?.notify(Ok(WorkspacePB.deserializeBinary(result.val)));
} else {
this._workspaceNotifier?.notify(result);
}
break;
case FolderNotification.DidUpdateWorkspaceApps:
this._appListNotifier?.notify(Ok(RepeatedAppPB.deserializeBinary(payload).items));
if (result.ok) {
this._appListNotifier?.notify(Ok(RepeatedAppPB.deserializeBinary(result.val).items));
} else {
this._appListNotifier?.notify(result);
}
break;
default:
break;

View File

@ -6,12 +6,19 @@ import {
UserEventSignUp,
UserEventUpdateUserProfile,
} from '../../../../services/backend/events/flowy-user';
import { SignInPayloadPB, SignUpPayloadPB } from '../../../../services/backend/models/flowy-user/auth';
import { UpdateUserProfilePayloadPB } from '../../../../services/backend/models/flowy-user/user_profile';
import { WorkspaceIdPB, CreateWorkspacePayloadPB } from '../../../../services/backend/models/flowy-folder/workspace';
import {
SignInPayloadPB,
SignUpPayloadPB,
UpdateUserProfilePayloadPB,
WorkspaceIdPB,
CreateWorkspacePayloadPB,
WorkspaceSettingPB,
WorkspacePB,
} from '../../../../services/backend';
import {
FolderEventCreateWorkspace,
FolderEventOpenWorkspace,
FolderEventReadCurrentWorkspace,
FolderEventReadWorkspaces,
} from '../../../../services/backend/events/flowy-folder';
@ -39,6 +46,15 @@ export class UserBackendService {
return UserEventUpdateUserProfile(payload);
};
getCurrentWorkspace = async (): Promise<WorkspaceSettingPB> => {
const result = await FolderEventReadCurrentWorkspace();
if (result.ok) {
return result.val;
} else {
throw new Error(result.val.msg);
}
};
getWorkspaces = () => {
const payload = WorkspaceIdPB.fromObject({});
return FolderEventReadWorkspaces(payload);
@ -49,9 +65,14 @@ export class UserBackendService {
return FolderEventOpenWorkspace(payload);
};
createWorkspace = (params: { name: string; desc: string }) => {
createWorkspace = async (params: { name: string; desc: string }): Promise<WorkspacePB> => {
const payload = CreateWorkspacePayloadPB.fromObject({ name: params.name, desc: params.desc });
return FolderEventCreateWorkspace(payload);
const result = await FolderEventCreateWorkspace(payload);
if (result.ok) {
return result.val;
} else {
throw new Error(result.val.msg);
}
};
signOut = () => {

View File

@ -0,0 +1,59 @@
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
import en from '../../../../../appflowy_flutter/assets/translations/en.json';
import ca_ES from '../../../../../appflowy_flutter/assets/translations/ca-ES.json';
import de_DE from '../../../../../appflowy_flutter/assets/translations/de-DE.json';
import es_VE from '../../../../../appflowy_flutter/assets/translations/es-VE.json';
import eu_ES from '../../../../../appflowy_flutter/assets/translations/eu-ES.json';
import fr_CA from '../../../../../appflowy_flutter/assets/translations/fr-CA.json';
import fr_FR from '../../../../../appflowy_flutter/assets/translations/fr-FR.json';
import hu_HU from '../../../../../appflowy_flutter/assets/translations/hu-HU.json';
import id_ID from '../../../../../appflowy_flutter/assets/translations/id-ID.json';
import it_IT from '../../../../../appflowy_flutter/assets/translations/it-IT.json';
import ja_JP from '../../../../../appflowy_flutter/assets/translations/ja-JP.json';
import ko_KR from '../../../../../appflowy_flutter/assets/translations/ko-KR.json';
import pl_PL from '../../../../../appflowy_flutter/assets/translations/pl-PL.json';
import pt_BR from '../../../../../appflowy_flutter/assets/translations/pt-BR.json';
import pt_PT from '../../../../../appflowy_flutter/assets/translations/pt-PT.json';
import ru_Ru from '../../../../../appflowy_flutter/assets/translations/ru-RU.json';
import sv from '../../../../../appflowy_flutter/assets/translations/sv.json';
import tr_TR from '../../../../../appflowy_flutter/assets/translations/tr-TR.json';
import zh_CN from '../../../../../appflowy_flutter/assets/translations/zh-CN.json';
import zh_TW from '../../../../../appflowy_flutter/assets/translations/zh-TW.json';
export default function () {
void i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
'ca-ES': { translation: ca_ES },
'de-DE': { translation: de_DE },
'es-VE': { translation: es_VE },
'eu-ES': { translation: eu_ES },
'fr-CA': { translation: fr_CA },
'fr-FR': { translation: fr_FR },
'hu-HU': { translation: hu_HU },
'id-ID': { translation: id_ID },
'it-IT': { translation: it_IT },
'ja-JP': { translation: ja_JP },
'ko-KR': { translation: ko_KR },
'pl-PL': { translation: pl_PL },
'pt-BR': { translation: pt_BR },
'pt-PT': { translation: pt_PT },
'ru-RU': { translation: ru_Ru },
sv: { translation: sv },
'tr-TR': { translation: tr_TR },
'zh-CN': { translation: zh_CN },
'zh-TW': { translation: zh_TW },
},
fallbackLng: 'en',
debug: true,
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
});
}

View File

@ -0,0 +1,15 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
const initialState = 'field1';
export const boardSlice = createSlice({
name: 'board',
initialState: initialState as string,
reducers: {
setGroupingFieldId: (state, action: PayloadAction<{ fieldId: string }>) => {
return action.payload.fieldId;
},
},
});
export const boardActions = boardSlice.actions;

View File

@ -1,12 +1,14 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { nanoid } from 'nanoid';
import { WorkspaceSettingPB } from '../../../../services/backend/models/flowy-folder/workspace';
export interface ICurrentUser {
id: string;
displayName: string;
email: string;
token: string;
id?: string;
displayName?: string;
email?: string;
token?: string;
isAuthenticated: boolean;
workspaceSetting?: WorkspaceSettingPB,
}
const initialState: ICurrentUser | null = {
@ -24,8 +26,10 @@ export const currentUserSlice = createSlice({
updateUser: (state, action: PayloadAction<ICurrentUser>) => {
return action.payload;
},
logout: (state) => {
state.isAuthenticated = false;
logout: () => {
return {
isAuthenticated: false,
};
},
},
});

View File

@ -0,0 +1,327 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { nanoid } from 'nanoid';
import { FieldType } from '../../../../services/backend/models/flowy-database/field_entities';
import { DateFormat, NumberFormat, SelectOptionColorPB, TimeFormat } from '../../../../services/backend';
export interface ISelectOption {
selectOptionId: string;
title: string;
color?: SelectOptionColorPB;
}
export interface IFieldOptions {
selectOptions?: ISelectOption[];
dateFormat?: DateFormat;
timeFormat?: TimeFormat;
includeTime?: boolean;
numberFormat?: NumberFormat;
}
export interface IDatabaseField {
fieldId: string;
title: string;
fieldType: FieldType;
fieldOptions: IFieldOptions;
}
export interface IDatabaseColumn {
fieldId: string;
sort: 'none' | 'asc' | 'desc';
filter?: any;
visible: boolean;
}
export interface ICellData {
rowId: string;
fieldId: string;
cellId: string;
data: string | number;
optionIds?: string[];
}
export type DatabaseCellMap = { [keys: string]: ICellData };
export interface IDatabaseRow {
rowId: string;
// key(fieldId) -> value(Cell)
cells: DatabaseCellMap;
}
export type DatabaseFieldMap = { [keys: string]: IDatabaseField };
export interface IDatabase {
title: string;
fields: DatabaseFieldMap;
rows: IDatabaseRow[];
columns: IDatabaseColumn[];
}
// key(databaseId) -> value(IDatabase)
const initialState: IDatabase = {
title: 'Database One',
columns: [
{
visible: true,
fieldId: 'field1',
sort: 'none',
},
{
visible: true,
fieldId: 'field2',
sort: 'none',
},
{
visible: true,
fieldId: 'field3',
sort: 'none',
},
{
visible: true,
fieldId: 'field4',
sort: 'none',
},
],
fields: {
field1: {
title: 'status',
fieldId: 'field1',
fieldType: FieldType.SingleSelect,
fieldOptions: {
selectOptions: [
{
selectOptionId: 'so1',
title: 'To Do',
color: SelectOptionColorPB.Orange,
},
{
selectOptionId: 'so2',
title: 'In Progress',
color: SelectOptionColorPB.Green,
},
{
selectOptionId: 'so3',
title: 'Done',
color: SelectOptionColorPB.Blue,
},
],
},
},
field2: {
title: 'name',
fieldId: 'field2',
fieldType: FieldType.RichText,
fieldOptions: {},
},
field3: {
title: 'percent',
fieldId: 'field3',
fieldType: FieldType.Number,
fieldOptions: {
numberFormat: NumberFormat.Num,
},
},
field4: {
title: 'tags',
fieldId: 'field4',
fieldType: FieldType.MultiSelect,
fieldOptions: {
selectOptions: [
{
selectOptionId: 'f4so1',
title: 'type1',
color: SelectOptionColorPB.Blue,
},
{
selectOptionId: 'f4so2',
title: 'type2',
color: SelectOptionColorPB.Aqua,
},
{
selectOptionId: 'f4so3',
title: 'type3',
color: SelectOptionColorPB.Purple,
},
{
selectOptionId: 'f4so4',
title: 'type4',
color: SelectOptionColorPB.Purple,
},
{
selectOptionId: 'f4so5',
title: 'type5',
color: SelectOptionColorPB.Purple,
},
{
selectOptionId: 'f4so6',
title: 'type6',
color: SelectOptionColorPB.Purple,
},
{
selectOptionId: 'f4so7',
title: 'type7',
color: SelectOptionColorPB.Purple,
},
],
},
},
},
rows: [
{
rowId: 'row1',
cells: {
field1: {
rowId: 'row1',
fieldId: 'field1',
cellId: 'cell11',
data: '',
optionIds: ['so1'],
},
field2: {
rowId: 'row1',
fieldId: 'field2',
cellId: 'cell12',
data: 'Card 1',
},
field3: {
rowId: 'row1',
fieldId: 'field3',
cellId: 'cell13',
data: 10,
},
field4: {
rowId: 'row1',
fieldId: 'field4',
cellId: 'cell14',
data: '',
optionIds: ['f4so2', 'f4so3', 'f4so4', 'f4so5', 'f4so6', 'f4so7'],
},
},
},
{
rowId: 'row2',
cells: {
field1: {
rowId: 'row2',
fieldId: 'field1',
cellId: 'cell21',
data: '',
optionIds: ['so1'],
},
field2: {
rowId: 'row2',
fieldId: 'field2',
cellId: 'cell22',
data: 'Card 2',
},
field3: {
rowId: 'row2',
fieldId: 'field3',
cellId: 'cell23',
data: 20,
},
field4: {
rowId: 'row2',
fieldId: 'field4',
cellId: 'cell24',
data: '',
optionIds: ['f4so1'],
},
},
},
],
};
export const databaseSlice = createSlice({
name: 'database',
initialState: initialState,
reducers: {
updateTitle: (state, action: PayloadAction<{ title: string }>) => {
state.title = action.payload.title;
},
addField: (state, action: PayloadAction<{ field: IDatabaseField }>) => {
const { field } = action.payload;
state.fields[field.fieldId] = field;
state.columns.push({
fieldId: field.fieldId,
sort: 'none',
visible: true,
});
state.rows = state.rows.map<IDatabaseRow>((r: IDatabaseRow) => {
const cells = r.cells;
cells[field.fieldId] = {
rowId: r.rowId,
fieldId: field.fieldId,
data: '',
cellId: nanoid(6),
};
return {
rowId: r.rowId,
cells: cells,
};
});
},
updateField: (state, action: PayloadAction<{ field: IDatabaseField }>) => {
const { field } = action.payload;
state.fields[field.fieldId] = field;
},
addFieldSelectOption: (state, action: PayloadAction<{ fieldId: string; option: ISelectOption }>) => {
const { fieldId, option } = action.payload;
const field = state.fields[fieldId];
const selectOptions = field.fieldOptions?.selectOptions;
if (selectOptions) {
selectOptions.push(option);
} else {
state.fields[field.fieldId].fieldOptions = {
...state.fields[field.fieldId].fieldOptions,
selectOptions: [option],
};
}
},
updateFieldSelectOption: (state, action: PayloadAction<{ fieldId: string; option: ISelectOption }>) => {
const { fieldId, option } = action.payload;
const field = state.fields[fieldId];
const selectOptions = field.fieldOptions?.selectOptions;
if (selectOptions) {
selectOptions[selectOptions.findIndex((o) => o.selectOptionId === option.selectOptionId)] = option;
}
},
addRow: (state) => {
const rowId = nanoid(6);
const cells: { [keys: string]: ICellData } = {};
Object.keys(state.fields).forEach((id) => {
cells[id] = {
rowId: rowId,
fieldId: id,
data: '',
cellId: nanoid(6),
};
});
const newRow: IDatabaseRow = {
rowId: rowId,
cells: cells,
};
state.rows.push(newRow);
},
updateCellValue: (source, action: PayloadAction<{ cell: ICellData }>) => {
const { cell } = action.payload;
const row = source.rows.find((r) => r.rowId === cell.rowId);
if (row) {
row.cells[cell.fieldId] = cell;
}
},
},
});
export const databaseActions = databaseSlice.actions;

View File

@ -0,0 +1,32 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface IErrorOptions {
display: boolean;
message: string;
}
const initialState: IErrorOptions = {
display: false,
message: '',
};
export const errorSlice = createSlice({
name: 'error',
initialState: initialState,
reducers: {
showError(state, action: PayloadAction<string>) {
return {
display: true,
message: action.payload,
};
},
hideError() {
return {
display: false,
message: '',
};
},
},
});
export const errorActions = errorSlice.actions;

View File

@ -1,7 +1,8 @@
import { FolderNotification } from '../../../../../services/backend';
import { FlowyError, FolderNotification } from '../../../../../services/backend';
import { NotificationParser, OnNotificationError } from '../../../../../services/backend/notifications';
import { Result } from 'ts-results';
declare type FolderNotificationCallback = (ty: FolderNotification, payload: Uint8Array) => void;
declare type FolderNotificationCallback = (ty: FolderNotification, payload: Result<Uint8Array, FlowyError>) => void;
export class FolderNotificationParser extends NotificationParser<FolderNotification> {
constructor(params: { id?: string; callback: FolderNotificationCallback; onError?: OnNotificationError }) {
@ -15,8 +16,7 @@ export class FolderNotificationParser extends NotificationParser<FolderNotificat
return FolderNotification.Unknown;
}
},
params.id,
params.onError
params.id
);
}
}

View File

@ -20,6 +20,9 @@ export const foldersSlice = createSlice({
deleteFolder(state, action: PayloadAction<{ id: string }>) {
return state.filter((f) => f.id !== action.payload.id);
},
clearFolders() {
return [];
},
},
});

View File

@ -25,6 +25,9 @@ export const pagesSlice = createSlice({
deletePage(state, action: PayloadAction<{ id: string }>) {
return state.filter((page) => page.id !== action.payload.id);
},
clearPages() {
return [];
},
},
});

View File

@ -0,0 +1,18 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface IWorkspace {
id?: string;
name?: string;
}
export const workspaceSlice = createSlice({
name: 'workspace',
initialState: {} as IWorkspace,
reducers: {
updateWorkspace: (state, action: PayloadAction<IWorkspace>) => {
return action.payload;
},
},
});
export const workspaceActions = workspaceSlice.actions;

View File

@ -12,6 +12,10 @@ import { pagesSlice } from './reducers/pages/slice';
import { navigationWidthSlice } from './reducers/navigation-width/slice';
import { currentUserSlice } from './reducers/current-user/slice';
import { gridSlice } from './reducers/grid/slice';
import { workspaceSlice } from './reducers/workspace/slice';
import { databaseSlice } from './reducers/database/slice';
import { boardSlice } from './reducers/board/slice';
import { errorSlice } from './reducers/error/slice';
const listenerMiddlewareInstance = createListenerMiddleware({
onError: () => console.error,
@ -24,6 +28,10 @@ const store = configureStore({
[navigationWidthSlice.name]: navigationWidthSlice.reducer,
[currentUserSlice.name]: currentUserSlice.reducer,
[gridSlice.name]: gridSlice.reducer,
[databaseSlice.name]: databaseSlice.reducer,
[boardSlice.name]: boardSlice.reducer,
[workspaceSlice.name]: workspaceSlice.reducer,
[errorSlice.name]: errorSlice.reducer,
},
middleware: (gDM) => gDM().prepend(listenerMiddlewareInstance.middleware),
});

View File

@ -1,7 +1,22 @@
import { useParams } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { Board } from '../components/board/Board';
export const BoardPage = () => {
const params = useParams();
const [databaseId, setDatabaseId] = useState('');
return <div className={'p-8'}>Board Page ID: {params.id}</div>;
useEffect(() => {
if (params?.id?.length) {
// setDatabaseId(params.id);
setDatabaseId('testDb');
}
}, [params]);
return (
<div className='flex h-full flex-col gap-8 px-8 pt-8'>
<h1 className='text-4xl font-bold'>Board</h1>
{databaseId?.length && <Board />}
</div>
);
};

View File

@ -0,0 +1,24 @@
import {
DocumentEventGetDocument,
DocumentVersionPB,
OpenDocumentPayloadPB,
} from '../../services/backend/events/flowy-document';
export const useDocument = () => {
const loadDocument = async (id: string): Promise<any> => {
const getDocumentResult = await DocumentEventGetDocument(
OpenDocumentPayloadPB.fromObject({
document_id: id,
version: DocumentVersionPB.V1,
})
);
if (getDocumentResult.ok) {
const pb = getDocumentResult.val;
return JSON.parse(pb.content);
} else {
throw new Error('get document error');
}
};
return { loadDocument };
};

View File

@ -1,8 +1,17 @@
import { useParams } from 'react-router-dom';
import { useEffect } from 'react';
import { useDocument } from './DocumentPage.hooks';
export const DocumentPage = () => {
const params = useParams();
const { loadDocument } = useDocument();
useEffect(() => {
void (async () => {
if (!params?.id) return;
const content: any = await loadDocument(params.id);
console.log(content);
})();
}, [params]);
return <div className={'p-8'}>Document Page ID: {params.id}</div>;
};

View File

@ -0,0 +1,9 @@
export const useGrid = () => {
const loadGrid = async (id: string) => {
console.log('loading grid');
};
return {
loadGrid,
};
};

View File

@ -6,8 +6,20 @@ import { GridTableRows } from '../components/grid/GridTableRows/GridTableRows';
import { GridTitle } from '../components/grid/GridTitle/GridTitle';
import { SearchInput } from '../components/_shared/SearchInput';
import { GridToolbar } from '../components/grid/GridToolbar/GridToolbar';
import { useParams } from 'react-router-dom';
import { useGrid } from './GridPage.hooks';
import { useEffect } from 'react';
export const GridPage = () => {
const params = useParams();
const { loadGrid } = useGrid();
useEffect(() => {
void (async () => {
if (!params?.id) return;
await loadGrid(params.id);
})();
}, [params]);
return (
<div className='mx-auto mt-8 flex flex-col gap-8 px-8'>
<h1 className='text-4xl font-bold'>Grid</h1>

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