Compare commits

..
Author SHA1 Message Date
Lucas 8c956afabd fix: show AI limit error toast if exceeding the AI response (#6505)
* fix: show AI limit error toast if exceeding the AI response

* test: add ai limit test
2024-10-08 18:28:07 +08:00
Richard Shiue f9fbf62283 test: fix flaky sort editor bloc test (#6504)
* test: fix flaky sort test

* test: experiment

* test: remove loops
2024-10-08 16:33:48 +08:00
Lucas 5f1f536181 feat: support editing name when creating a new page on mobile (#6501)
* feat: support editing name when creating a new page on mobile

* chore: add defaultName in layout extension

* test: add cover title test on mobile

* fix: cover title test on mobile

* feat: add integration runner 4

* chore: update translations

* chore: disable subpage feature
2024-10-08 14:29:07 +08:00
Lucas ba59514464 fix: ignore same workspace icon update (#6496)
* fix: ignore same workspace icon update

* feat: pressing esc to exit editing mode in cover title

* fix: integration test

* fix: unable to use redo/undo in cover title

* chore: update collab version b56d051

* feat: show add emoji & add cover button when hovering on the title

* test: add hover on title test

* fix: unable to use arrow right in cover title

* fix: unable to select title sometimes

* fix: integration test

* fix: cloud integration test

* fix: cloud integration test

* fix: integration test
2024-10-08 11:46:15 +08:00
Annie 6bb6750be7 chore: update CHANGELOG.md (#6502)
change log for v0.7.1
2024-10-08 09:22:34 +08:00
Kilu.He 19a3df6d47 feat: support shortcuts on web (#6474)
* fix: add tab and shift+tab test cases on web

* fix: support BIUS on web

* fix: add BIUS test cases

* fix: add markdown to block test cases

* fix: add markdown to block test cases
2024-10-07 18:30:39 +08:00
Mathias Mogensen 87408fd2a9 fix: sub page block review fixes (#6491)
* fix: sub page block review fixes

* test: fix tests
2024-10-07 12:08:44 +02:00
Richard Shiue 9ee39f45c9 test: rewrite bloc tests (#6492) 2024-10-07 16:41:20 +08:00
Lucas a763304386 fix: fix potential text empty when editing doc (#6495) 2024-10-07 16:40:36 +08:00
Nathan.fooo f8ce501b53 chore: fix anon user integration test (#6493)
* chore: fix anon user integration test

* chore: fix env

* chore: appflowy cloud version

* chore: appflowy cloud version
2024-10-07 13:31:47 +08:00
nathan 54d449647c fix: clippy and test 2024-10-07 11:20:59 +08:00
Lucas 552fd39d74 fix: share button doesn't response under macOS 15 (#6487)
* fix: share button doesn't response under macos 15

* chore: enable macos integration test

* Revert "chore: enable macos integration test"

This reverts commit 32c49c9f36.

* chore: update comment
2024-10-07 10:18:11 +08:00
nathan 01d73f2753 chore: clippy 2024-10-07 09:44:00 +08:00
Nathan.fooo 75da496128 fix: calculation bug (#6489)
* chore: upgrade collab

* fix: calculate value

* chore: optimize calculate

* chore: cal when open database

* chore: update calculation when filter change

* chore: use same runtime
2024-10-07 08:57:42 +08:00
134 changed files with 4579 additions and 1444 deletions

No files matched your search

@@ -52,7 +52,7 @@ runs:
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev network-manager libmpv-dev mpv
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev network-manager
shell: bash
- name: Enable Flutter Desktop
@@ -75,4 +75,4 @@ runs:
sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 &
sudo apt-get install network-manager
flutter test ${{ inputs.test_path }} -d Linux --coverage
shell: bash
shell: bash
+39 -33
View File
@@ -39,7 +39,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -73,7 +73,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ windows-2019 ]
os: [windows-2019]
include:
- os: windows-2019
flutter_profile: development-windows-x86
@@ -100,7 +100,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ macos-latest ]
os: [macos-latest]
include:
- os: macos-latest
flutter_profile: development-mac-x86_64
@@ -122,12 +122,12 @@ jobs:
flutter_profile: ${{ matrix.flutter_profile }}
unit_test:
needs: [ prepare-linux ]
needs: [prepare-linux]
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -173,7 +173,7 @@ jobs:
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev libmpv-dev mpv
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev
fi
shell: bash
@@ -216,11 +216,11 @@ jobs:
shell: bash
cloud_integration_test:
needs: [ prepare-linux ]
needs: [prepare-linux]
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -292,7 +292,7 @@ jobs:
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev libmpv-dev mpv
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev
shell: bash
- name: Enable Flutter Desktop
@@ -325,12 +325,12 @@ jobs:
# split the integration tests into different machines to minimize the time
integration_test_1:
needs: [ prepare-linux ]
needs: [prepare-linux]
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
target: "x86_64-unknown-linux-gnu"
@@ -339,12 +339,6 @@ jobs:
- name: Checkout source code
uses: actions/checkout@v4
- name: Install video dependency
run: |
sudo apt-get update
sudo apt-get -y install libmpv-dev mpv
shell: bash
- name: Flutter Integration Test 1
uses: ./.github/actions/flutter_integration_test
with:
@@ -355,12 +349,12 @@ jobs:
rust_target: ${{ matrix.target }}
integration_test_2:
needs: [ prepare-linux ]
needs: [prepare-linux]
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
target: "x86_64-unknown-linux-gnu"
@@ -369,12 +363,6 @@ jobs:
- name: Checkout source code
uses: actions/checkout@v4
- name: Install video dependency
run: |
sudo apt-get update
sudo apt-get -y install libmpv-dev mpv
shell: bash
- name: Flutter Integration Test 2
uses: ./.github/actions/flutter_integration_test
with:
@@ -385,12 +373,12 @@ jobs:
rust_target: ${{ matrix.target }}
integration_test_3:
needs: [ prepare-linux ]
needs: [prepare-linux]
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
target: "x86_64-unknown-linux-gnu"
@@ -399,12 +387,6 @@ jobs:
- name: Checkout source code
uses: actions/checkout@v4
- name: Install video dependency
run: |
sudo apt-get update
sudo apt-get -y install libmpv-dev mpv
shell: bash
- name: Flutter Integration Test 3
uses: ./.github/actions/flutter_integration_test
with:
@@ -413,3 +395,27 @@ jobs:
rust_toolchain: ${{ env.RUST_TOOLCHAIN }}
cargo_make_version: ${{ env.CARGO_MAKE_VERSION }}
rust_target: ${{ matrix.target }}
integration_test_4:
needs: [prepare-linux]
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
include:
- os: ubuntu-latest
target: "x86_64-unknown-linux-gnu"
runs-on: ${{ matrix.os }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Flutter Integration Test 4
uses: ./.github/actions/flutter_integration_test
with:
test_path: integration_test/desktop_runner_4.dart
flutter_version: ${{ env.FLUTTER_VERSION }}
rust_toolchain: ${{ env.RUST_TOOLCHAIN }}
cargo_make_version: ${{ env.CARGO_MAKE_VERSION }}
rust_target: ${{ matrix.target }}
+1 -1
View File
@@ -368,7 +368,7 @@ jobs:
sudo apt-get update
sudo apt-get install -y build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev
sudo apt-get install keybinder-3.0
sudo apt-get install -y alien libnotify-dev libmpv-dev mpv
sudo apt-get install -y alien libnotify-dev
source $HOME/.cargo/env
cargo install --force cargo-make
cargo install --force duckscript_cli
+2 -2
View File
@@ -46,7 +46,7 @@ jobs:
- name: Ensure AppFlowy-Cloud is Running with Correct Version
working-directory: AppFlowy-Cloud
env:
APPFLOWY_CLOUD_VERSION: 0.6.4-amd64
APPFLOWY_CLOUD_VERSION: 0.6.29-amd64
APPFLOWY_HISTORY_VERSION: 0.6.29-amd64
APPFLOWY_WORKER_VERSION: 0.6.29-amd64
run: |
@@ -135,7 +135,7 @@ jobs:
- name: Ensure AppFlowy-Cloud is Running with Correct Version
working-directory: AppFlowy-Cloud
env:
APPFLOWY_CLOUD_VERSION: 0.6.4-amd64
APPFLOWY_CLOUD_VERSION: 0.6.29-amd64
APPFLOWY_HISTORY_VERSION: 0.6.29-amd64
APPFLOWY_WORKER_VERSION: 0.6.29-amd64
run: |
+18
View File
@@ -1,4 +1,22 @@
# Release Notes
## Version 0.7.1 - 07/10/2024
### New Features
- Copy link to share and open it in a browser
- Enable the ability to edit the page title within the body of the document
- Filter by last modified, created at, or a date range
- Allow customization of database property icons
- Support CTRL/CMD+X to delete the current line when the selection is collapsed in the document
- Support window tiling on macOS
- Add filters to grid views on mobile
- Create and manage workspaces on mobile
- Automatically convert property types for imported CSV files
### Bug Fixes
- Fixed calculations with filters applied
- Fixed issues with importing data folders into a cloud account
- Fixed French IME backtick issues
- Fixed selection gesture bugs on mobile
## Version 0.7.0 - 19/09/2024
### New Features
- Support reordering blocks in document with drag and drop
@@ -3,9 +3,13 @@ import 'sidebar/sidebar_move_page_test.dart' as sidebar_move_page_test;
import 'uncategorized/uncategorized_test_runner.dart'
as uncategorized_test_runner;
import 'workspace/workspace_test_runner.dart' as workspace_test_runner;
import 'data_migration/data_migration_test_runner.dart' as data_migration_test_runner;
import 'data_migration/data_migration_test_runner.dart'
as data_migration_test_runner;
import 'set_env.dart' as preset_af_cloud_env_test;
Future<void> main() async {
preset_af_cloud_env_test.main();
data_migration_test_runner.main();
// uncategorized
uncategorized_test_runner.main();
@@ -18,5 +22,4 @@ Future<void> main() async {
// sidebar
sidebar_move_page_test.main();
}
@@ -2,7 +2,7 @@ import 'package:appflowy/env/cloud_env.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../../shared/util.dart';
import '../../shared/util.dart';
// This test is meaningless, just for preventing the CI from failing.
void main() {
@@ -1,9 +1,7 @@
import 'appflowy_cloud_auth_test.dart' as appflowy_cloud_auth_test;
import 'empty_test.dart' as preset_af_cloud_env_test;
import 'user_setting_sync_test.dart' as user_sync_test;
void main() async {
preset_af_cloud_env_test.main();
appflowy_cloud_auth_test.main();
user_sync_test.main();
}
@@ -0,0 +1,68 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import 'package:universal_platform/universal_platform.dart';
import '../../../shared/constants.dart';
import '../../../shared/database_test_op.dart';
import '../../../shared/dir.dart';
import '../../../shared/emoji.dart';
import '../../../shared/mock/mock_file_picker.dart';
import '../../../shared/util.dart';
import '../../../shared/workspace.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('workspace icon:', () {
testWidgets('remove icon from workspace', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
await tester.openWorkspaceMenu();
// click the workspace icon
await tester.tapButton(
find.descendant(
of: find.byType(WorkspaceMenuItem),
matching: find.byType(WorkspaceIcon),
),
);
// click the remove icon button
await tester.tapButton(
find.text(LocaleKeys.button_remove.tr()),
);
// nothing should happen
expect(
find.text(LocaleKeys.workspace_updateIconSuccess.tr()),
findsNothing,
);
});
});
}
@@ -3,6 +3,7 @@ import 'package:integration_test/integration_test.dart';
import 'change_name_and_icon_test.dart' as change_name_and_icon_test;
import 'collaborative_workspace_test.dart' as collaborative_workspace_test;
import 'share_menu_test.dart' as share_menu_test;
import 'workspace_icon_test.dart' as workspace_icon_test;
import 'workspace_settings_test.dart' as workspace_settings_test;
void main() {
@@ -12,4 +13,5 @@ void main() {
share_menu_test.main();
collaborative_workspace_test.main();
change_name_and_icon_test.main();
workspace_icon_test.main();
}
@@ -26,11 +26,16 @@ import '../../shared/util.dart';
// - [x] Redo adding a SubPageBlock (Expect the view to be restored)
// - [x] Redo delete of a SubPageBlock (Expect the view to be moved to trash again)
// - [x] Renaming a child view (Expect the view name to be updated in the document)
// - [x] Deleting a view (to trash) linked to a SubPageBlock shows a hint that the view is in trash (Expect a hint to be shown)
// - [x] Deleting a view (in trash) linked to a SubPageBlock deletes the SubPageBlock (Expect the SubPageBlock to be deleted)
// - [x] Deleting a view (to trash) linked to a SubPageBlock deleted the SubPageBlock (Expect the SubPageBlock to be deleted)
// - [x] Duplicating a SubPageBlock node from Action Menu (Expect a new view is created under current view with same content and name + (copy))
// - [x] Dragging a SubPageBlock node to a new position in the document (Expect everything to be normal)
/// The defaut page name is empty, if we're looking for a "text" we can look for
/// [LocaleKeys.menuAppHeader_defaultNewPageName] but it won't work for eg. hoverOnPageName
/// as it looks at the text provided instead of the actual displayed text.
///
const _defaultPageName = "";
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -66,9 +71,9 @@ void main() {
pageName: 'SubPageBlock',
layout: ViewLayoutPB.Document,
);
await tester.pumpAndSettle();
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.hoverOnPageName(_defaultPageName);
await tester.renamePage('Child page');
await tester.pumpAndSettle();
@@ -94,8 +99,7 @@ void main() {
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.hoverOnPageName(_defaultPageName);
await tester.renamePage('Child page');
await tester.pumpAndSettle();
@@ -151,8 +155,7 @@ void main() {
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.hoverOnPageName(_defaultPageName);
await tester.renamePage('Child page');
await tester.pumpAndSettle();
@@ -213,8 +216,7 @@ void main() {
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.hoverOnPageName(_defaultPageName);
await tester.renamePage('Child page');
await tester.pumpAndSettle();
@@ -258,8 +260,7 @@ void main() {
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.hoverOnPageName(_defaultPageName);
await tester.renamePage('Child page');
await tester.pumpAndSettle();
@@ -300,39 +301,6 @@ void main() {
expect(find.text('Child page (copy)'), findsNothing);
});
testWidgets('Undo adding a SubPageBlock', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock');
await tester.insertSubPageFromSlashMenu(true);
await tester.expandOrCollapsePage(
pageName: 'SubPageBlock',
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.renamePage('Child page');
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNWidgets(2));
await tester.editor.tapLineOfEditorAt(0);
// Undo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(SubPageBlockComponent), findsNothing);
expect(find.text('Child page'), findsNothing);
});
testWidgets('Undo delete of a SubPageBlock', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@@ -345,8 +313,7 @@ void main() {
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.hoverOnPageName(_defaultPageName);
await tester.renamePage('Child page');
await tester.pumpAndSettle();
@@ -370,55 +337,6 @@ void main() {
expect(find.byType(SubPageBlockComponent), findsOneWidget);
});
// Redo: undoing adding a subpage block, then redoing to bring it back
// -> Add a subpage block
// -> Undo
// -> Redo
testWidgets('Redo adding of a SubPageBlock', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock');
await tester.insertSubPageFromSlashMenu(true);
await tester.expandOrCollapsePage(
pageName: 'SubPageBlock',
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.renamePage('Child page');
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNWidgets(2));
await tester.editor.tapLineOfEditorAt(0);
// Undo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(SubPageBlockComponent), findsNothing);
expect(find.text('Child page'), findsNothing);
// Redo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isShiftPressed: true,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(SubPageBlockComponent), findsOneWidget);
expect(find.text('Child page'), findsNWidgets(2));
});
// Redo: undoing deleting a subpage block, then redoing to delete it again
// -> Add a subpage block
// -> Delete
@@ -436,8 +354,7 @@ void main() {
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.hoverOnPageName(_defaultPageName);
await tester.renamePage('Child page');
await tester.pumpAndSettle();
@@ -476,8 +393,7 @@ void main() {
expect(find.text('Child page'), findsNothing);
});
testWidgets('Delete a view first to trash, then from trash',
(tester) async {
testWidgets('Delete a view from sidebar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock');
@@ -489,38 +405,17 @@ void main() {
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.hoverOnPageName(_defaultPageName);
await tester.renamePage('Child page');
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNWidgets(2));
expect(find.byType(SubPageBlockComponent), findsOneWidget);
final hintText = LocaleKeys.document_plugins_subPage_inTrashHint.tr();
expect(find.text(hintText), findsNothing);
await tester.hoverOnPageName('Child page');
await tester.tapDeletePageButton();
await tester.pumpAndSettle(const Duration(seconds: 1));
expect(find.text(hintText), findsOne);
// Go to trash
await tester.tapTrashButton();
await tester.pumpAndSettle();
// Tap on delete all button
await tester.tap(find.text(LocaleKeys.trash_deleteAll.tr()));
await tester.pumpAndSettle();
// Tap ok to delete app pages in trash
await tester.tap(find.text(LocaleKeys.button_delete.tr()));
await tester.pumpAndSettle();
await tester.openPage('SubPageBlock');
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNothing);
expect(find.byType(SubPageBlockComponent), findsNothing);
});
@@ -537,8 +432,7 @@ void main() {
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.hoverOnPageName(_defaultPageName);
await tester.renamePage('Child page');
await tester.pumpAndSettle();
@@ -578,10 +472,6 @@ void main() {
expect(afterNode.type, SubPageBlockKeys.type);
expect(afterNode.type, beforeNode.type);
expect(find.byType(SubPageBlockComponent), findsOneWidget);
expect(
find.text(LocaleKeys.document_plugins_subPage_inTrashHint.tr()),
findsNothing,
);
});
});
}
@@ -602,6 +492,10 @@ extension _SubPageTestHelper on WidgetTester {
offset: 100,
);
// Navigate to the previous page to see the SubPageBlock
await openPage('SubPageBlock');
await pumpAndSettle();
await pumpUntilFound(find.byType(SubPageBlockComponent));
}
}
@@ -24,7 +24,7 @@ import 'document_with_outline_block_test.dart' as document_with_outline_block;
import 'document_with_toggle_list_test.dart' as document_with_toggle_list_test;
import 'edit_document_test.dart' as document_edit_test;
void startTesting() {
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// Document integration tests
@@ -47,4 +47,6 @@ void startTesting() {
document_more_actions_test.main();
document_with_file_test.main();
document_shortcuts_test.main();
// Don't add new tests here. Add them to document_test_runner_2.dart
}
@@ -0,0 +1,14 @@
import 'package:integration_test/integration_test.dart';
import 'document_app_lifecycle_test.dart' as document_app_lifecycle_test;
import 'document_title_test.dart' as document_title_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// Document integration tests
document_title_test.main();
// Disable subPage test temporarily, enable it in version 0.7.2
// document_sub_page_test.main();
document_app_lifecycle_test.main();
}
@@ -1,3 +1,4 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -221,6 +222,85 @@ void main() {
expect(newTitle, findsOneWidget);
});
testWidgets('execute undo and redo in title', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent();
final title = tester.editor.findDocumentTitle('');
await tester.enterText(title, _testDocumentName);
// press a random key to make the undo stack not empty
await tester.simulateKeyEvent(LogicalKeyboardKey.keyA);
await tester.pumpAndSettle();
// undo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed: !UniversalPlatform.isMacOS,
isMetaPressed: UniversalPlatform.isMacOS,
);
// wait for the undo to be applied
await tester.pumpAndSettle(Durations.long1);
// expect the title is empty
expect(
tester
.widget<TextField>(
tester.editor.findDocumentTitle(''),
)
.controller
?.text,
'',
);
// redo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed: !UniversalPlatform.isMacOS,
isMetaPressed: UniversalPlatform.isMacOS,
isShiftPressed: true,
);
await tester.pumpAndSettle(Durations.short1);
if (UniversalPlatform.isMacOS) {
expect(
tester
.widget<TextField>(
tester.editor.findDocumentTitle(_testDocumentName),
)
.controller
?.text,
_testDocumentName,
);
}
});
testWidgets('escape key should exit the editing mode', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent();
final title = tester.editor.findDocumentTitle('');
await tester.enterText(title, _testDocumentName);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(
tester
.widget<TextField>(
tester.editor.findDocumentTitle(_testDocumentName),
)
.focusNode
?.hasFocus,
isFalse,
);
});
testWidgets('press arrow down key in title, check if the cursor flashes',
(tester) async {
await tester.initializeAppFlowy();
@@ -248,5 +328,24 @@ void main() {
),
);
});
testWidgets(
'hover on the cover title, check if the add icon & add cover button are shown',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent();
final title = tester.editor.findDocumentTitle('');
await tester.hoverOnWidget(
title,
onHover: () async {
expect(find.byType(DocumentCoverWidget), findsOneWidget);
},
);
await tester.pumpAndSettle();
});
});
}
@@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -14,7 +13,7 @@ import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('cover image', () {
group('cover image:', () {
testWidgets('document cover tests', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@@ -6,7 +6,6 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart';
import 'package:appflowy/startup/startup.dart';
@@ -160,10 +159,7 @@ void main() {
findsOneWidget,
);
expect(find.byType(UploadImageMenu), findsOneWidget);
await tester.tapButtonWithName('Unsplash');
await tester.pumpUntilFound(find.byType(UnsplashImageWidget));
expect(find.byType(UnsplashImageWidget), findsOneWidget);
expect(find.text('Unsplash'), findsOneWidget);
});
});
@@ -1,6 +1,6 @@
import 'package:integration_test/integration_test.dart';
import 'desktop/document/document_test_runner.dart' as document_test_runner;
import 'desktop/document/document_test_runner_1.dart' as document_test_runner;
import 'desktop/uncategorized/empty_test.dart' as first_test;
import 'desktop/uncategorized/switch_folder_test.dart' as switch_folder_test;
@@ -15,7 +15,7 @@ Future<void> runIntegration1OnDesktop() async {
first_test.main();
switch_folder_test.main();
document_test_runner.startTesting();
document_test_runner.main();
// DON'T add more tests here. This is the first test runner for desktop.
// DON'T add more tests here.
}
@@ -3,16 +3,12 @@ import 'package:integration_test/integration_test.dart';
import 'desktop/board/board_test_runner.dart' as board_test_runner;
import 'desktop/database/database_row_cover_test.dart'
as database_row_cover_test;
import 'desktop/document/document_title_test.dart' as document_title_test;
import 'desktop/document/document_app_lifecycle_test.dart'
as document_app_lifecycle_test;
import 'desktop/document/document_sub_page_test.dart' as document_sub_page_test;
import 'desktop/grid/grid_edit_row_test.dart' as grid_edit_row_test_runner;
import 'desktop/grid/grid_filter_and_sort_test.dart'
as grid_filter_and_sort_test_runner;
import 'desktop/grid/grid_reopen_test.dart' as grid_reopen_test_runner;
import 'desktop/grid/grid_reorder_row_test.dart'
as grid_reorder_row_test_runner;
import 'desktop/grid/grid_edit_row_test.dart' as grid_edit_row_test_runner;
import 'desktop/grid/grid_row_test.dart' as grid_create_row_test_runner;
import 'desktop/settings/settings_runner.dart' as settings_test_runner;
import 'desktop/sidebar/sidebar_test_runner.dart' as sidebar_test_runner;
@@ -51,7 +47,6 @@ Future<void> runIntegration3OnDesktop() async {
grid_filter_and_sort_test_runner.main();
grid_edit_row_test_runner.main();
zoom_in_out_test.main();
document_title_test.main();
document_sub_page_test.main();
document_app_lifecycle_test.main();
// DON'T add more tests here. Add them to desktop_runner_4.dart
}
@@ -0,0 +1,17 @@
import 'package:integration_test/integration_test.dart';
import 'desktop/document/document_test_runner_2.dart' as document_test_runner_2;
import 'desktop/uncategorized/empty_test.dart' as first_test;
Future<void> main() async {
await runIntegration4OnDesktop();
}
Future<void> runIntegration4OnDesktop() async {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// This test must be run first, otherwise the CI will fail.
first_test.main();
document_test_runner_2.main();
}
@@ -0,0 +1,12 @@
import 'package:integration_test/integration_test.dart';
import 'page_style_test.dart' as page_style_test;
import 'title_test.dart' as title_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// Document integration tests
title_test.main();
page_style_test.main();
}
@@ -0,0 +1,76 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart';
import 'package:appflowy/mobile/presentation/home/home.dart';
import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import '../../shared/dir.dart';
import '../../shared/mock/mock_file_picker.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('document title:', () {
testWidgets('create a new page, the title should be empty', (tester) async {
await tester.launchInAnonymousMode();
final createPageButton = find.byKey(
BottomNavigationBarItemType.add.valueKey,
);
await tester.tapButton(createPageButton);
expect(find.byType(MobileDocumentScreen), findsOneWidget);
final title = tester.editor.findDocumentTitle('');
expect(title, findsOneWidget);
final textField = tester.widget<TextField>(title);
expect(textField.focusNode!.hasFocus, isTrue);
// input new name and press done button
const name = 'test document';
await tester.enterText(title, name);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
final newTitle = tester.editor.findDocumentTitle(name);
expect(newTitle, findsOneWidget);
expect(textField.controller!.text, name);
// the document should get focus
final editor = tester.widget<AppFlowyEditorPage>(
find.byType(AppFlowyEditorPage),
);
expect(
editor.editorState.selection,
Selection.collapsed(Position(path: [0])),
);
});
});
}
@@ -3,6 +3,7 @@ import 'dart:io';
import 'desktop_runner_1.dart';
import 'desktop_runner_2.dart';
import 'desktop_runner_3.dart';
import 'desktop_runner_4.dart';
import 'mobile_runner.dart';
/// The main task runner for all integration tests in AppFlowy.
@@ -17,6 +18,7 @@ Future<void> main() async {
await runIntegration1OnDesktop();
await runIntegration2OnDesktop();
await runIntegration3OnDesktop();
await runIntegration4OnDesktop();
} else if (Platform.isIOS || Platform.isAndroid) {
await runIntegrationOnMobile();
} else {
@@ -1,11 +1,5 @@
import 'dart:io';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
@@ -17,10 +11,12 @@ import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/presentation/screens/screens.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_new_page_button.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart';
@@ -40,6 +36,10 @@ import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'emoji.dart';
@@ -324,14 +324,10 @@ extension CommonOperations on WidgetTester {
}
await pumpAndSettle();
final defaultPageName = layout == ViewLayoutPB.Document
? '' // the document name is empty by default
: LocaleKeys.menuAppHeader_defaultNewPageName.tr();
// hover on it and change it's name
if (name != null) {
await hoverOnPageName(
defaultPageName,
layout.defaultName,
layout: layout,
onHover: () async {
await renamePage(name);
@@ -345,7 +341,7 @@ extension CommonOperations on WidgetTester {
if (openAfterCreated) {
await openPage(
// if the name is null, use the default name
name ?? defaultPageName,
name ?? layout.defaultName,
layout: layout,
);
await pumpAndSettle();
@@ -1,14 +1,12 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_title.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart';
@@ -19,8 +17,11 @@ import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:universal_platform/universal_platform.dart';
import 'util.dart';
@@ -329,9 +330,13 @@ class EditorOperations {
await tester.pumpAndSettle(Durations.short1);
}
Finder findDocumentTitle(String title) {
Finder findDocumentTitle(String? title) {
final parent = UniversalPlatform.isDesktop
? find.byType(CoverTitle)
: find.byType(DocumentImmersiveCover);
return find.descendant(
of: find.byType(CoverTitle),
of: parent,
matching: find.byWidgetPredicate(
(widget) {
if (widget is! TextField) {
@@ -342,6 +347,10 @@ class EditorOperations {
return true;
}
if (title == null) {
return true;
}
if (title.isEmpty) {
return widget.controller?.text.isEmpty ?? false;
}
@@ -1,7 +1,7 @@
import 'dart:io';
import 'package:appflowy/startup/tasks/device_info_task.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:universal_platform/universal_platform.dart';
class CocoaWindowChannel {
CocoaWindowChannel._();
@@ -44,7 +44,14 @@ class MoveWindowDetectorState extends State<MoveWindowDetector> {
@override
Widget build(BuildContext context) {
if (!Platform.isMacOS) {
// the frameless window is only supported on macOS
if (!UniversalPlatform.isMacOS) {
return widget.child ?? const SizedBox.shrink();
}
// For the macOS version 15 or higher, we can control the window position by using system APIs
if (ApplicationInfo.macOSMajorVersion != null &&
ApplicationInfo.macOSMajorVersion! >= 15) {
return widget.child ?? const SizedBox.shrink();
}
@@ -441,4 +441,15 @@ class PageStyleCover {
bool get isCustomImage => type == PageStyleCoverImageType.customImage;
bool get isUnsplashImage => type == PageStyleCoverImageType.unsplashImage;
bool get isLocalImage => type == PageStyleCoverImageType.localImage;
@override
bool operator ==(Object other) {
if (other is! PageStyleCover) {
return false;
}
return type == other.type && value == other.value;
}
@override
int get hashCode => Object.hash(type, value);
}
@@ -7,6 +7,7 @@ import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -93,7 +94,7 @@ enum MobilePaneActionType {
Navigator.of(sheetContext).pop();
viewBloc.add(
ViewEvent.createView(
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
layout.defaultName,
layout,
section: spaceType!.toViewSectionPB,
),
@@ -93,7 +93,7 @@ class MobileSpace extends StatelessWidget {
Navigator.of(sheetContext).pop();
context.read<SpaceBloc>().add(
SpaceEvent.createPage(
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
name: layout.defaultName,
layout: layout,
index: 0,
),
@@ -1,4 +1,3 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/mobile/presentation/home/favorite_folder/favorite_space.dart';
import 'package:appflowy/mobile/presentation/home/home_space/home_space.dart';
@@ -11,10 +10,10 @@ import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -187,7 +186,7 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
if (context.read<SpaceBloc>().state.spaces.isNotEmpty) {
context.read<SpaceBloc>().add(
SpaceEvent.createPage(
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
name: layout.defaultName,
layout: layout,
),
);
@@ -195,7 +194,7 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
// only support create document in section
context.read<SidebarSectionsBloc>().add(
SidebarSectionsEvent.createRootViewInSection(
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
name: layout.defaultName,
index: 0,
viewSection: FolderSpaceType.public.toViewSectionPB,
),
@@ -29,23 +29,41 @@ final PropertyValueNotifier<ViewLayoutPB?> mobileCreateNewPageNotifier =
final ValueNotifier<BottomNavigationBarActionType> bottomNavigationBarType =
ValueNotifier(BottomNavigationBarActionType.home);
const _homeLabel = 'home';
const _addLabel = 'add';
const _notificationLabel = 'notification';
enum BottomNavigationBarItemType {
home,
add,
notification;
String get label {
return switch (this) {
BottomNavigationBarItemType.home => 'home',
BottomNavigationBarItemType.add => 'add',
BottomNavigationBarItemType.notification => 'notification',
};
}
ValueKey get valueKey {
return ValueKey(label);
}
}
final _items = <BottomNavigationBarItem>[
const BottomNavigationBarItem(
label: _homeLabel,
icon: FlowySvg(FlowySvgs.m_home_unselected_m),
activeIcon: FlowySvg(FlowySvgs.m_home_selected_m, blendMode: null),
BottomNavigationBarItem(
key: BottomNavigationBarItemType.home.valueKey,
label: BottomNavigationBarItemType.home.label,
icon: const FlowySvg(FlowySvgs.m_home_unselected_m),
activeIcon: const FlowySvg(FlowySvgs.m_home_selected_m, blendMode: null),
),
const BottomNavigationBarItem(
label: _addLabel,
icon: FlowySvg(FlowySvgs.m_home_add_m),
BottomNavigationBarItem(
key: BottomNavigationBarItemType.add.valueKey,
label: BottomNavigationBarItemType.add.label,
icon: const FlowySvg(FlowySvgs.m_home_add_m),
),
const BottomNavigationBarItem(
label: _notificationLabel,
icon: _NotificationNavigationBarItemIcon(),
activeIcon: _NotificationNavigationBarItemIcon(
BottomNavigationBarItem(
key: BottomNavigationBarItemType.notification.valueKey,
label: BottomNavigationBarItemType.notification.label,
icon: const _NotificationNavigationBarItemIcon(),
activeIcon: const _NotificationNavigationBarItemIcon(
isActive: true,
),
),
@@ -234,11 +252,11 @@ class _HomePageNavigationBar extends StatelessWidget {
closePopupMenu();
final label = _items[bottomBarIndex].label;
if (label == _addLabel) {
if (label == BottomNavigationBarItemType.add.label) {
// show an add dialog
mobileCreateNewPageNotifier.value = ViewLayoutPB.Document;
return;
} else if (label == _notificationLabel) {
} else if (label == BottomNavigationBarItemType.notification.label) {
getIt<ReminderBloc>().add(const ReminderEvent.refresh());
}
// When navigating to a new branch, it's recommended to use the goBranch
@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
@@ -8,6 +9,7 @@ import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -260,7 +262,9 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
// title
Expanded(
child: FlowyText.regular(
widget.view.name,
widget.view.name.isEmpty
? LocaleKeys.menuAppHeader_defaultNewPageName.tr()
: widget.view.name,
fontSize: 16.0,
figmaLineHeight: 20.0,
overflow: TextOverflow.ellipsis,
@@ -174,7 +174,8 @@ class FieldController {
(FilterChangesetNotificationPB changeset) {
_filterNotifier?.filters =
_filterListFromPBs(changeset.filters.items);
_updateFieldInfos();
_fieldNotifier.fieldInfos =
_updateFieldInfos(_fieldNotifier.fieldInfos);
},
(err) => Log.error(err),
);
@@ -396,7 +397,7 @@ class FieldController {
(updatedFields, fieldInfos) =
await updateFields(changeset.updatedFields, fieldInfos);
_fieldNotifier.fieldInfos = fieldInfos;
_fieldNotifier.fieldInfos = _updateFieldInfos(fieldInfos);
for (final listener in _updatedFieldCallbacks.values) {
listener(updatedFields);
}
@@ -465,25 +466,22 @@ class FieldController {
_fieldSettings.clear();
_fieldSettings.addAll(setting.fieldSettings.items);
_updateFieldInfos();
_fieldNotifier.fieldInfos = _updateFieldInfos(_fieldNotifier.fieldInfos);
}
/// Attach sort, filter, group information and field settings to `FieldInfo`
void _updateFieldInfos() {
final List<FieldInfo> newFieldInfos = [];
for (final field in _fieldNotifier.fieldInfos) {
newFieldInfos.add(
field.copyWith(
fieldSettings: _fieldSettings
.firstWhereOrNull((setting) => setting.fieldId == field.id),
isGroupField: _groupConfigurationByFieldId[field.id] != null,
hasFilter: getFilterByFieldId(field.id) != null,
hasSort: getSortByFieldId(field.id) != null,
),
);
}
_fieldNotifier.fieldInfos = newFieldInfos;
List<FieldInfo> _updateFieldInfos(List<FieldInfo> fieldInfos) {
return fieldInfos
.map(
(field) => field.copyWith(
fieldSettings: _fieldSettings
.firstWhereOrNull((setting) => setting.fieldId == field.id),
isGroupField: _groupConfigurationByFieldId[field.id] != null,
hasFilter: getFilterByFieldId(field.id) != null,
hasSort: getSortByFieldId(field.id) != null,
),
)
.toList();
}
/// Load all of the fields. This is required when opening the database
@@ -506,7 +504,8 @@ class FieldController {
_loadAllFieldSettings(),
_loadSettings(),
]);
_updateFieldInfos();
_fieldNotifier.fieldInfos =
_updateFieldInfos(_fieldNotifier.fieldInfos);
return FlowyResult.success(null);
},
@@ -14,11 +14,12 @@ import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart';
import 'package:appflowy/util/int64_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:equatable/equatable.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/widgets.dart';
abstract class DatabaseFilter {
abstract class DatabaseFilter extends Equatable {
const DatabaseFilter({
required this.filterId,
required this.fieldId,
@@ -112,16 +113,18 @@ abstract class DatabaseFilter {
}
final class TextFilter extends DatabaseFilter {
const TextFilter({
TextFilter({
required super.filterId,
required super.fieldId,
required super.fieldType,
required this.condition,
required this.content,
});
required String content,
}) {
this.content = canAttachContent ? content : "";
}
final TextFilterConditionPB condition;
final String content;
late final String content;
@override
String get conditionName => condition.filterName;
@@ -182,19 +185,24 @@ final class TextFilter extends DatabaseFilter {
content: content ?? this.content,
);
}
@override
List<Object?> get props => [filterId, fieldId, condition, content];
}
final class NumberFilter extends DatabaseFilter {
const NumberFilter({
NumberFilter({
required super.filterId,
required super.fieldId,
required super.fieldType,
required this.condition,
required this.content,
});
required String content,
}) {
this.content = canAttachContent ? content : "";
}
final NumberFilterConditionPB condition;
final String content;
late final String content;
@override
String get conditionName => condition.filterName;
@@ -253,6 +261,9 @@ final class NumberFilter extends DatabaseFilter {
content: content ?? this.content,
);
}
@override
List<Object?> get props => [filterId, fieldId, condition, content];
}
final class CheckboxFilter extends DatabaseFilter {
@@ -289,6 +300,9 @@ final class CheckboxFilter extends DatabaseFilter {
condition: condition ?? this.condition,
);
}
@override
List<Object?> get props => [filterId, fieldId, condition];
}
final class ChecklistFilter extends DatabaseFilter {
@@ -325,19 +339,33 @@ final class ChecklistFilter extends DatabaseFilter {
condition: condition ?? this.condition,
);
}
@override
List<Object?> get props => [filterId, fieldId, condition];
}
final class SelectOptionFilter extends DatabaseFilter {
const SelectOptionFilter({
SelectOptionFilter({
required super.filterId,
required super.fieldId,
required super.fieldType,
required this.condition,
required this.optionIds,
});
required List<String> optionIds,
}) {
if (canAttachContent) {
if (fieldType == FieldType.SingleSelect &&
(condition == SelectOptionFilterConditionPB.OptionIs ||
condition == SelectOptionFilterConditionPB.OptionIsNot) &&
optionIds.isNotEmpty) {
this.optionIds.add(optionIds.first);
} else {
this.optionIds.addAll(optionIds);
}
}
}
final SelectOptionFilterConditionPB condition;
final List<String> optionIds;
final List<String> optionIds = [];
@override
String get conditionName => condition.i18n;
@@ -428,6 +456,9 @@ final class SelectOptionFilter extends DatabaseFilter {
field.fieldType == FieldType.SingleSelect
? const SingleSelectOptionFilterDelegateImpl()
: const MultiSelectOptionFilterDelegateImpl();
@override
List<Object?> get props => [filterId, fieldId, condition, optionIds];
}
enum DateTimeFilterCondition {
@@ -491,7 +522,8 @@ enum DateTimeFilterCondition {
}
static List<DateTimeFilterCondition> availableConditionsForFieldType(
FieldType fieldType,) {
FieldType fieldType,
) {
final result = [...values];
if (fieldType == FieldType.CreatedTime ||
fieldType == FieldType.LastEditedTime) {
@@ -504,20 +536,37 @@ enum DateTimeFilterCondition {
}
final class DateTimeFilter extends DatabaseFilter {
const DateTimeFilter({
DateTimeFilter({
required super.filterId,
required super.fieldId,
required super.fieldType,
required this.condition,
this.timestamp,
this.start,
this.end,
});
DateTime? timestamp,
DateTime? start,
DateTime? end,
}) {
if (canAttachContent) {
if (condition == DateFilterConditionPB.DateStartsBetween ||
condition == DateFilterConditionPB.DateEndsBetween) {
this.start = start;
this.end = end;
this.timestamp = null;
} else {
this.timestamp = timestamp;
this.start = null;
this.end = null;
}
} else {
this.timestamp = null;
this.start = null;
this.end = null;
}
}
final DateFilterConditionPB condition;
final DateTime? timestamp;
final DateTime? start;
final DateTime? end;
late final DateTime? timestamp;
late final DateTime? start;
late final DateTime? end;
@override
String get conditionName => condition.toCondition().filterName;
@@ -654,6 +703,10 @@ final class DateTimeFilter extends DatabaseFilter {
timestamp: timestamp,
);
}
@override
List<Object?> get props =>
[filterId, fieldId, condition, timestamp, start, end];
}
final class TimeFilter extends DatabaseFilter {
@@ -703,4 +756,7 @@ final class TimeFilter extends DatabaseFilter {
content: content ?? this.content,
);
}
@override
List<Object?> get props => [filterId, fieldId, condition, content];
}
@@ -1,8 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:appflowy/plugins/document/application/doc_sync_state_listener.dart';
import 'package:appflowy/plugins/document/application/document_awareness_metadata.dart';
import 'package:appflowy/plugins/document/application/document_collab_adapter.dart';
@@ -34,6 +32,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'
Position,
paragraphNode;
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -96,19 +95,29 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
@override
Future<void> close() async {
isClosing = true;
_updateSelectionDebounce.dispose();
_syncThrottle.dispose();
await checkDocumentIntegrity();
await _cancelSubscriptions();
_clearEditorState();
return super.close();
}
Future<void> _cancelSubscriptions() async {
await _documentService.syncAwarenessStates(documentId: documentId);
await _documentListener.stop();
await _syncStateListener.stop();
await _viewListener?.stop();
await _transactionSubscription?.cancel();
await _documentService.closeDocument(viewId: documentId);
}
void _clearEditorState() {
_updateSelectionDebounce.dispose();
_syncThrottle.dispose();
_syncTimer?.cancel();
_syncTimer = null;
state.editorState?.service.keyboardService?.closeKeyboard();
state.editorState?.dispose();
return super.close();
}
Future<void> _onDocumentEvent(
@@ -389,6 +398,34 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
metadata: jsonEncode(metadata.toJson()),
);
}
// this is only used for debug mode
Future<void> checkDocumentIntegrity() async {
if (!enableDocumentInternalLog) {
return;
}
final cloudDocResult =
await _documentService.getDocument(documentId: documentId);
final cloudDoc = cloudDocResult.fold((s) => s, (f) => null)?.toDocument();
final localDoc = state.editorState?.document;
if (cloudDoc == null || localDoc == null) {
return;
}
final cloudJson = cloudDoc.toJson();
final localJson = localDoc.toJson();
final deepEqual = const DeepCollectionEquality().equals(
cloudJson,
localJson,
);
if (!deepEqual) {
Log.error('document integrity check failed');
// Enable it to debug the document integrity check failed
// Log.error('cloud doc: $cloudJson');
// Log.error('local doc: $localJson');
assert(false, 'document integrity check failed');
}
}
}
@freezed
@@ -40,7 +40,7 @@ class TransactionAdapter {
Future<void> apply(Transaction transaction, EditorState editorState) async {
if (enableDocumentInternalLog) {
Log.debug(
Log.info(
'[TransactionAdapter] 2. apply transaction begin ${transaction.hashCode} in $hashCode',
);
}
@@ -48,7 +48,7 @@ class TransactionAdapter {
await _applyInternal(transaction, editorState);
if (enableDocumentInternalLog) {
Log.debug(
Log.info(
'[TransactionAdapter] 3. apply transaction end ${transaction.hashCode} in $hashCode',
);
}
@@ -60,17 +60,12 @@ class TransactionAdapter {
) async {
final stopwatch = Stopwatch()..start();
if (enableDocumentInternalLog) {
Log.debug('transaction => ${transaction.toJson()}');
Log.info('transaction => ${transaction.toJson()}');
}
final actions = transaction.operations
.map((op) => op.toBlockAction(editorState, documentId))
.whereNotNull()
.expand((element) => element)
.toList(growable: false); // avoid lazy evaluation
final textActions = actions.where(
(e) =>
e.textDeltaType != TextDeltaType.none && e.textDeltaPayloadPB != null,
);
final actions = transactionToBlockActions(transaction, editorState);
final textActions = filterTextDeltaActions(actions);
final actionCostTime = stopwatch.elapsedMilliseconds;
for (final textAction in textActions) {
final payload = textAction.textDeltaPayloadPB!;
@@ -82,8 +77,8 @@ class TransactionAdapter {
delta: payload.delta,
);
if (enableDocumentInternalLog) {
Log.debug(
'[editor_transaction_adapter] create external text: ${payload.delta}',
Log.info(
'[editor_transaction_adapter] create external text: id: ${payload.textId} delta: ${payload.delta}',
);
}
} else if (type == TextDeltaType.update) {
@@ -93,18 +88,18 @@ class TransactionAdapter {
delta: payload.delta,
);
if (enableDocumentInternalLog) {
Log.debug(
'[editor_transaction_adapter] update external text: ${payload.delta}',
Log.info(
'[editor_transaction_adapter] update external text: id: ${payload.textId} delta: ${payload.delta}',
);
}
}
}
final blockActions =
actions.map((e) => e.blockActionPB).toList(growable: false);
final blockActions = filterBlockActions(actions);
for (final action in blockActions) {
if (enableDocumentInternalLog) {
Log.debug(
Log.info(
'[editor_transaction_adapter] action => ${action.toProto3Json()}',
);
}
@@ -114,14 +109,44 @@ class TransactionAdapter {
documentId: documentId,
actions: blockActions,
);
final elapsed = stopwatch.elapsedMilliseconds;
stopwatch.stop();
if (enableDocumentInternalLog) {
Log.debug(
Log.info(
'[editor_transaction_adapter] apply transaction cost: total $elapsed ms, converter action $actionCostTime ms, apply action ${elapsed - actionCostTime} ms',
);
}
}
List<BlockActionWrapper> transactionToBlockActions(
Transaction transaction,
EditorState editorState,
) {
return transaction.operations
.map((op) => op.toBlockAction(editorState, documentId))
.whereNotNull()
.expand((element) => element)
.toList(growable: false); // avoid lazy evaluation
}
List<BlockActionWrapper> filterTextDeltaActions(
List<BlockActionWrapper> actions,
) {
return actions
.where(
(e) =>
e.textDeltaType != TextDeltaType.none &&
e.textDeltaPayloadPB != null,
)
.toList(growable: false);
}
List<BlockActionPB> filterBlockActions(
List<BlockActionWrapper> actions,
) {
return actions.map((e) => e.blockActionPB).toList(growable: false);
}
}
extension BlockAction on Operation {
@@ -294,6 +319,15 @@ extension on UpdateOperation {
externalType: _kExternalTextType,
);
if (enableDocumentInternalLog) {
Log.info('create text delta: $textDeltaPayloadPB');
}
// update the external text id and external type to the block
blockActionPB.payload.block
..externalId = textId
..externalType = _kExternalTextType;
actions.add(
BlockActionWrapper(
blockActionPB: blockActionPB,
@@ -310,6 +344,15 @@ extension on UpdateOperation {
delta: jsonEncode(diff),
);
if (enableDocumentInternalLog) {
Log.info('update text delta: $textDeltaPayloadPB');
}
// update the external text id and external type to the block
blockActionPB.payload.block
..externalId = textId
..externalType = _kExternalTextType;
actions.add(
BlockActionWrapper(
blockActionPB: blockActionPB,
@@ -1,7 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
@@ -28,6 +26,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:cross_file/cross_file.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:universal_platform/universal_platform.dart';
@@ -162,6 +161,8 @@ class _DocumentPageState extends State<DocumentPage>
child = BlocBuilder<DocumentPageStyleBloc, DocumentPageStyleState>(
builder: (context, styleState) => AppFlowyEditorPage(
editorState: state.editorState!,
// if the view's name is empty, focus on the title
autoFocus: widget.view.name.isEmpty ? false : null,
styleCustomizer: EditorStyleCustomizer(
context: context,
width: width,
@@ -1,8 +1,5 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/background_color/theme_background_color.dart';
@@ -21,6 +18,8 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
@@ -382,7 +381,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
dateOrReminderSlashMenuItem,
photoGallerySlashMenuItem,
fileSlashMenuItem,
subPageSlashMenuItem,
// disable subPageSlashMenuItem temporarily, enable it in version 0.7.2
// subPageSlashMenuItem,
];
}
@@ -15,6 +15,7 @@ import 'package:appflowy/shared/google_fonts_extension.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@@ -57,6 +58,9 @@ class _DocumentImmersiveCoverState extends State<DocumentImmersiveCover> {
void initState() {
super.initState();
selectionNotifier?.addListener(_unfocus);
if (widget.view.name.isEmpty) {
focusNode.requestFocus();
}
}
@override
@@ -161,12 +165,12 @@ class _DocumentImmersiveCoverState extends State<DocumentImmersiveCover> {
controller: textEditingController,
focusNode: focusNode,
minFontSize: 18.0,
decoration: const InputDecoration(
decoration: InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
disabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: '',
hintText: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
contentPadding: EdgeInsets.zero,
),
scrollController: scrollController,
@@ -183,11 +187,15 @@ class _DocumentImmersiveCoverState extends State<DocumentImmersiveCover> {
const Duration(milliseconds: 300),
() => _rename(name),
),
onSubmitted: (name) => Debounce.debounce(
'rename',
const Duration(milliseconds: 300),
() => _rename(name),
),
onSubmitted: (name) {
// focus on the document
_createNewLine();
Debounce.debounce(
'rename',
const Duration(milliseconds: 300),
() => _rename(name),
);
},
);
}
@@ -314,4 +322,35 @@ class _DocumentImmersiveCoverState extends State<DocumentImmersiveCover> {
scrollController.position.jumpTo(0);
context.read<ViewBloc>().add(ViewEvent.rename(name));
}
Future<void> _createNewLine() async {
focusNode.unfocus();
final selection = textEditingController.selection;
final text = textEditingController.text;
// split the text into two lines based on the cursor position
final parts = [
text.substring(0, selection.baseOffset),
text.substring(selection.baseOffset),
];
textEditingController.text = parts[0];
final editorState = context.read<DocumentBloc>().state.editorState;
if (editorState == null) {
Log.info('editorState is null when creating new line');
return;
}
final transaction = editorState.transaction;
transaction.insertNode([0], paragraphNode(text: parts[1]));
await editorState.apply(transaction);
// update selection instead of using afterSelection in transaction,
// because it will cause the cursor to jump
await editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [0])),
// trigger the keyboard service.
reason: SelectionUpdateReason.uiEvent,
);
}
}
@@ -66,6 +66,14 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
Log.info('cover title got focus, clear the editor selection');
editorState.selection = null;
}
if (isTitleFocused) {
Log.info('cover title got focus, disable keyboard service');
editorState.service.keyboardService?.disable();
} else {
Log.info('cover title lost focus, enable keyboard service');
editorState.service.keyboardService?.enable();
}
});
editorState.selectionNotifier.addListener(() {
@@ -98,6 +106,8 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
.copyWith(fontSize: 38.0, fontWeight: FontWeight.w700);
final width = context.read<DocumentAppearanceCubit>().state.width;
return BlocConsumer<ViewBloc, ViewState>(
listenWhen: (previous, current) =>
previous.view.name != current.view.name,
listener: _onListen,
builder: (context, state) {
final appearance = context.read<DocumentAppearanceCubit>().state;
@@ -188,11 +198,18 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
return _moveCursorToNextLine(event.logicalKey);
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
return _moveCursorToNextLine(event.logicalKey);
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
return _exitEditing();
}
return KeyEventResult.ignored;
}
KeyEventResult _exitEditing() {
titleFocusNode.unfocus();
return KeyEventResult.handled;
}
Future<void> _createNewLine() async {
titleFocusNode.unfocus();
@@ -223,7 +240,7 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
final text = titleTextController.text;
// if the cursor is not at the end of the text, ignore the event
if (lineCount != 1 &&
if ((key == LogicalKeyboardKey.arrowRight || lineCount != 1) &&
(!selection.isCollapsed || text.length != selection.extentOffset)) {
return KeyEventResult.ignored;
}
@@ -64,6 +64,9 @@ enum CoverType {
}
}
// This key is used to intercept the selection event in the document cover widget.
const _interceptorKey = 'document_cover_widget_interceptor';
class DocumentCoverWidget extends StatefulWidget {
const DocumentCoverWidget({
super.key,
@@ -93,6 +96,7 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
bool get hasCover =>
coverType != CoverType.none ||
(cover != null && cover?.type != PageStyleCoverImageType.none);
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
String viewIcon = '';
PageStyleCover? cover;
@@ -102,6 +106,13 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
final titleTextController = TextEditingController();
final titleFocusNode = FocusNode();
final isCoverTitleHovered = ValueNotifier<bool>(false);
late final gestureInterceptor = SelectionGestureInterceptor(
key: _interceptorKey,
canTap: (details) => !_isTapInBounds(details.globalPosition),
canPanStart: (details) => !_isDragInBounds(details.globalPosition),
);
@override
void initState() {
@@ -112,6 +123,8 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
view = widget.view;
titleTextController.text = view.name;
widget.node.addListener(_reload);
widget.editorState.service.selectionService
.registerGestureInterceptor(gestureInterceptor);
viewListener = ViewListener(viewId: widget.view.id)
..start(
@@ -134,6 +147,9 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
widget.node.removeListener(_reload);
titleTextController.dispose();
titleFocusNode.dispose();
isCoverTitleHovered.dispose();
widget.editorState.service.selectionService
.unregisterGestureInterceptor(_interceptorKey);
super.dispose();
}
@@ -156,6 +172,7 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
hasCover: hasCover,
hasIcon: hasIcon,
offset: offset,
isCoverTitleHovered: isCoverTitleHovered,
),
),
if (hasCover)
@@ -177,8 +194,12 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
),
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: CoverTitle(
view: widget.view,
child: MouseRegion(
onEnter: (event) => isCoverTitleHovered.value = true,
onExit: (event) => isCoverTitleHovered.value = false,
child: CoverTitle(
view: widget.view,
),
),
),
],
@@ -288,6 +309,24 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
overwrite: true,
);
}
bool _isTapInBounds(Offset offset) {
if (_renderBox == null) {
return false;
}
final localPosition = _renderBox!.globalToLocal(offset);
return _renderBox!.paintBounds.contains(localPosition);
}
bool _isDragInBounds(Offset offset) {
if (_renderBox == null) {
return false;
}
final localPosition = _renderBox!.globalToLocal(offset);
return _renderBox!.paintBounds.contains(localPosition);
}
}
@visibleForTesting
@@ -300,6 +339,7 @@ class DocumentHeaderToolbar extends StatefulWidget {
required this.hasIcon,
required this.onIconOrCoverChanged,
required this.offset,
required this.isCoverTitleHovered,
});
final Node node;
@@ -309,6 +349,7 @@ class DocumentHeaderToolbar extends StatefulWidget {
final void Function({(CoverType, String?)? cover, String? icon})
onIconOrCoverChanged;
final double offset;
final ValueNotifier<bool> isCoverTitleHovered;
@override
State<DocumentHeaderToolbar> createState() => _DocumentHeaderToolbarState();
@@ -328,12 +369,17 @@ class _DocumentHeaderToolbarState extends State<DocumentHeaderToolbar> {
padding: EdgeInsets.symmetric(horizontal: widget.offset),
child: SizedBox(
height: 28,
child: Visibility(
visible: !isHidden || isPopoverOpen,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: buildRowChildren(),
),
child: ValueListenableBuilder<bool>(
valueListenable: widget.isCoverTitleHovered,
builder: (context, isHovered, child) {
return Visibility(
visible: !isHidden || isPopoverOpen || isHovered,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: buildRowChildren(),
),
);
},
),
),
);
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/user/application/ai_service.dart';
@@ -45,11 +46,12 @@ class SmartEditBloc extends Bloc<SmartEditEvent, SmartEditState> {
isCanceled = true;
await _exit();
},
update: (result, isLoading) async {
update: (result, isLoading, aiError) async {
emit(
state.copyWith(
result: result,
loading: isLoading,
requestError: aiError,
),
);
},
@@ -73,7 +75,7 @@ class SmartEditBloc extends Bloc<SmartEditEvent, SmartEditState> {
await aiRepositoryCompleter.future;
if (rewrite) {
add(const SmartEditEvent.update('', true));
add(const SmartEditEvent.update('', true, null));
}
if (enableLogging) {
@@ -91,7 +93,7 @@ class SmartEditBloc extends Bloc<SmartEditEvent, SmartEditState> {
if (enableLogging) {
Log.info('[smart_edit] start generating');
}
add(const SmartEditEvent.update('', true));
add(const SmartEditEvent.update('', true, null));
},
onProcess: (text) async {
if (isCanceled) {
@@ -102,7 +104,7 @@ class SmartEditBloc extends Bloc<SmartEditEvent, SmartEditState> {
Log.debug('[smart_edit] onProcess: $text');
}
final newResult = state.result + text;
add(SmartEditEvent.update(newResult, false));
add(SmartEditEvent.update(newResult, false, null));
},
onEnd: () async {
if (isCanceled) {
@@ -111,7 +113,7 @@ class SmartEditBloc extends Bloc<SmartEditEvent, SmartEditState> {
if (enableLogging) {
Log.info('[smart_edit] end generating');
}
add(SmartEditEvent.update('${state.result}\n', false));
add(SmartEditEvent.update('${state.result}\n', false, null));
},
onError: (error) async {
if (isCanceled) {
@@ -120,7 +122,9 @@ class SmartEditBloc extends Bloc<SmartEditEvent, SmartEditState> {
if (enableLogging) {
Log.info('[smart_edit] onError: $error');
}
add(SmartEditEvent.update('', false, error));
await _exit();
await _clearSelection();
},
);
}
@@ -207,6 +211,14 @@ class SmartEditBloc extends Bloc<SmartEditEvent, SmartEditState> {
),
);
}
Future<void> _clearSelection() async {
final selection = editorState.selection;
if (selection == null) {
return;
}
editorState.selection = null;
}
}
@freezed
@@ -219,7 +231,11 @@ class SmartEditEvent with _$SmartEditEvent {
const factory SmartEditEvent.replace() = _Replace;
const factory SmartEditEvent.insertBelow() = _InsertBelow;
const factory SmartEditEvent.cancel() = _Cancel;
const factory SmartEditEvent.update(String result, bool isLoading) = _Update;
const factory SmartEditEvent.update(
String result,
bool isLoading,
AIError? error,
) = _Update;
}
@freezed
@@ -228,6 +244,7 @@ class SmartEditState with _$SmartEditState {
required bool loading,
required String result,
required SmartEditAction action,
@Default(null) AIError? requestError,
}) = _SmartEditState;
factory SmartEditState.initial(SmartEditAction action) => SmartEditState(
@@ -2,6 +2,8 @@ import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ai_limit_dialog.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_bloc.dart';
import 'package:appflowy/startup/startup.dart';
@@ -12,6 +14,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:toastification/toastification.dart';
class SmartEditBlockKeys {
const SmartEditBlockKeys._();
@@ -123,41 +126,44 @@ class _SmartEditBlockComponentWidgetState
return BlocProvider.value(
value: smartEditBloc,
child: AppFlowyPopover(
controller: popoverController,
direction: PopoverDirection.bottomWithLeftAligned,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
offset: const Offset(40, 0), // align the editor block
windowPadding: EdgeInsets.zero,
constraints: BoxConstraints(maxWidth: width),
canClose: () async {
final completer = Completer<bool>();
final state = smartEditBloc.state;
if (state.result.isEmpty) {
completer.complete(true);
} else {
await showCancelAndConfirmDialog(
context: context,
title: LocaleKeys.document_plugins_discardResponse.tr(),
description: '',
confirmLabel: LocaleKeys.button_discard.tr(),
onConfirm: () => completer.complete(true),
onCancel: () => completer.complete(false),
child: BlocListener<SmartEditBloc, SmartEditState>(
listener: _onListen,
child: AppFlowyPopover(
controller: popoverController,
direction: PopoverDirection.bottomWithLeftAligned,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
offset: const Offset(40, 0), // align the editor block
windowPadding: EdgeInsets.zero,
constraints: BoxConstraints(maxWidth: width),
canClose: () async {
final completer = Completer<bool>();
final state = smartEditBloc.state;
if (state.result.isEmpty) {
completer.complete(true);
} else {
await showCancelAndConfirmDialog(
context: context,
title: LocaleKeys.document_plugins_discardResponse.tr(),
description: '',
confirmLabel: LocaleKeys.button_discard.tr(),
onConfirm: () => completer.complete(true),
onCancel: () => completer.complete(false),
);
}
return completer.future;
},
onClose: _removeNode,
popupBuilder: (BuildContext popoverContext) {
return BlocProvider.value(
// request the result when opening the popover
value: smartEditBloc..add(const SmartEditEvent.started()),
child: const SmartEditInputContent(),
);
}
return completer.future;
},
onClose: _removeNode,
popupBuilder: (BuildContext popoverContext) {
return BlocProvider.value(
// request the result when opening the popover
value: smartEditBloc..add(const SmartEditEvent.started()),
child: const SmartEditInputContent(),
);
},
child: const SizedBox(
width: double.infinity,
},
child: const SizedBox(
width: double.infinity,
),
),
),
);
@@ -179,6 +185,21 @@ class _SmartEditBlockComponentWidgetState
final transaction = editorState.transaction..deleteNode(widget.node);
editorState.apply(transaction);
}
void _onListen(BuildContext context, SmartEditState state) {
final error = state.requestError;
if (error != null) {
if (error.isLimitExceeded) {
showAILimitDialog(context, error.message);
} else {
showToastNotification(
context,
message: error.message,
type: ToastificationType.error,
);
}
}
}
}
class SmartEditInputContent extends StatelessWidget {
@@ -7,12 +7,15 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'exit_edit_mode_command.dart';
final List<CommandShortcutEvent> defaultCommandShortcutEvents = [
...commandShortcutEvents.map((e) => e.copyWith()),
];
// Command shortcuts are order-sensitive. Verify order when modifying.
List<CommandShortcutEvent> commandShortcutEvents = [
customExitEditingCommand,
backspaceToTitle,
arrowUpToTitle,
@@ -40,6 +43,7 @@ List<CommandShortcutEvent> commandShortcutEvents = [
toggleTodoListCommand,
undoCommand,
redoCommand,
exitEditingCommand,
].contains(shortcut),
),
@@ -0,0 +1,24 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
/// End key event.
///
/// - support
/// - desktop
/// - web
///
final CommandShortcutEvent customExitEditingCommand = CommandShortcutEvent(
key: 'exit the editing mode',
getDescription: () => AppFlowyEditorL10n.current.cmdExitEditing,
command: 'escape',
handler: _exitEditingCommandHandler,
);
CommandShortcutEventHandler _exitEditingCommandHandler = (editorState) {
if (editorState.selection == null) {
return KeyEventResult.ignored;
}
editorState.selection = null;
editorState.service.keyboardService?.closeKeyboard();
return KeyEventResult.handled;
};
@@ -1,16 +1,20 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_transaction_handler/block_transaction_handler.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart';
import 'package:appflowy/plugins/trash/application/trash_service.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/snap_bar.dart';
import 'package:universal_platform/universal_platform.dart';
class SubPageBlockTransactionHandler extends BlockTransactionHandler {
SubPageBlockTransactionHandler() : super(blockType: SubPageBlockKeys.type);
@@ -138,19 +142,29 @@ class SubPageBlockTransactionHandler extends BlockTransactionHandler {
final viewOrResult = await ViewBackendService.createView(
layoutType: ViewLayoutPB.Document,
parentViewId: parentViewId,
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
name: '',
);
await viewOrResult.fold(
(view) async {
final transaction = editorState.transaction
..updateNode(node, {SubPageBlockKeys.viewId: view.id});
await editorState.apply(
await editorState
.apply(
transaction,
withUpdateSelection: false,
options: const ApplyOptions(recordUndo: false),
);
editorState.reload();
)
.then((_) async {
editorState.reload();
// Open the new page
if (UniversalPlatform.isDesktop) {
getIt<TabsBloc>().openPlugin(view);
} else {
await context.pushView(view);
}
});
},
(error) async {
Log.error(error);
@@ -2,14 +2,17 @@ import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/trash/application/trash_service.dart';
import 'package:appflowy/plugins/trash/application/trash_listener.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
@@ -86,15 +89,16 @@ class SubPageBlockComponentState extends State<SubPageBlockComponent>
final subPageKey = GlobalKey();
ViewListener? viewListener;
TrashListener? trashListener;
Future<ViewPB?>? viewFuture;
bool isHovering = false;
bool isHandlingPaste = false;
bool isInTrash = false;
EditorState get editorState => context.read<EditorState>();
String? parentId;
@override
void initState() {
super.initState();
@@ -102,13 +106,8 @@ class SubPageBlockComponentState extends State<SubPageBlockComponent>
if (viewId != null) {
viewFuture = fetchView(viewId);
viewListener = ViewListener(viewId: viewId)
..start(
onViewUpdated: (view) {
pageMemorizer[view.id] = view;
viewFuture = fetchView(viewId);
editorState.reload();
},
);
..start(onViewUpdated: onViewUpdated);
trashListener = TrashListener()..start(trashUpdated: didUpdateTrash);
}
}
@@ -121,17 +120,51 @@ class SubPageBlockComponentState extends State<SubPageBlockComponent>
viewFuture = fetchView(viewId);
viewListener?.stop();
viewListener = ViewListener(viewId: viewId)
..start(
onViewUpdated: (view) {
pageMemorizer[view.id] = view;
viewFuture = fetchView(viewId);
editorState.reload();
},
);
..start(onViewUpdated: onViewUpdated);
}
super.didUpdateWidget(oldWidget);
}
void didUpdateTrash(FlowyResult<List<TrashPB>, FlowyError> trashOrFailed) {
final trashList = trashOrFailed.toNullable();
if (trashList == null) {
return;
}
final viewId = node.attributes[SubPageBlockKeys.viewId];
if (trashList.any((t) => t.id == viewId)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
final transaction = editorState.transaction..deleteNode(node);
editorState.apply(
transaction,
withUpdateSelection: false,
options: const ApplyOptions(recordUndo: false),
);
}
});
}
}
void onViewUpdated(ViewPB view) {
pageMemorizer[view.id] = view;
viewFuture = Future<ViewPB>.value(view);
editorState.reload();
if (parentId != view.parentViewId && parentId != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
final transaction = editorState.transaction..deleteNode(node);
editorState.apply(
transaction,
withUpdateSelection: false,
options: const ApplyOptions(recordUndo: false),
);
}
});
}
}
@override
void dispose() {
viewListener?.stop();
@@ -150,21 +183,22 @@ class SubPageBlockComponentState extends State<SubPageBlockComponent>
final view = snapshot.data;
if (view == null) {
// Delete this node if the view is not found
WidgetsBinding.instance.addPostFrameCallback((_) {
final transaction = editorState.transaction..deleteNode(node);
editorState.apply(
transaction,
withUpdateSelection: false,
options: const ApplyOptions(recordUndo: false),
);
if (mounted) {
final transaction = editorState.transaction..deleteNode(node);
editorState.apply(
transaction,
withUpdateSelection: false,
options: const ApplyOptions(recordUndo: false),
);
}
});
return const SizedBox.shrink();
}
Widget child = Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(vertical: 2),
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => isHovering = true),
@@ -202,13 +236,11 @@ class SubPageBlockComponentState extends State<SubPageBlockComponent>
decoration: BoxDecoration(
color: isHovering
? Theme.of(context).colorScheme.secondary
: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
: null,
borderRadius: BorderRadius.circular(4),
),
child: SizedBox(
height: 36,
height: 32,
child: Row(
children: [
const HSpace(10),
@@ -223,32 +255,38 @@ class SubPageBlockComponentState extends State<SubPageBlockComponent>
child: view.defaultIcon(),
),
const HSpace(10),
FlowyText(
view.name,
fontSize: textStyle.fontSize,
fontWeight: textStyle.fontWeight,
lineHeight: textStyle.height,
),
if (isInTrash) ...[
const HSpace(4),
FlowyText(
LocaleKeys.document_plugins_subPage_inTrashHint.tr(),
Flexible(
child: FlowyText(
view.name.trim().isEmpty
? LocaleKeys.menuAppHeader_defaultNewPageName
.tr()
: view.name,
fontSize: textStyle.fontSize,
fontWeight: textStyle.fontWeight,
lineHeight: textStyle.height,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
] else if (isHandlingPaste) ...[
),
if (isHandlingPaste) ...[
FlowyText(
LocaleKeys.document_plugins_subPage_handlingPasteHint.tr(),
LocaleKeys
.document_plugins_subPage_handlingPasteHint
.tr(),
fontSize: textStyle.fontSize,
fontWeight: textStyle.fontWeight,
lineHeight: textStyle.height,
color: Theme.of(context).hintColor,
),
const HSpace(10),
const CircularProgressIndicator(),
const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(
strokeWidth: 1.5,
),
),
],
const HSpace(10),
],
),
),
@@ -278,20 +316,19 @@ class SubPageBlockComponentState extends State<SubPageBlockComponent>
);
if (view == null) {
// try to fetch from trash
final trashViews = await TrashService().readTrash();
final trash = trashViews.fold(
(l) => l.items.firstWhereOrNull((trash) => trash.id == pageId),
(r) => null,
);
if (trash != null) {
isInTrash = true;
return ViewPB()
..id = trash.id
..name = trash.name;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
final transaction = editorState.transaction..deleteNode(node);
editorState.apply(
transaction,
withUpdateSelection: false,
options: const ApplyOptions(recordUndo: false),
);
}
});
}
parentId = view?.parentViewId;
return view;
}
@@ -1,7 +1,6 @@
import 'package:flutter/widgets.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/widgets.dart';
/// Undo
///
@@ -15,6 +14,10 @@ final CommandShortcutEvent customUndoCommand = CommandShortcutEvent(
command: 'ctrl+z',
macOSCommand: 'cmd+z',
handler: (editorState) {
// if the selection is null, it means the keyboard service is disabled
if (editorState.selection == null) {
return KeyEventResult.ignored;
}
EditorNotification.undo().post();
return KeyEventResult.handled;
},
@@ -32,6 +35,9 @@ final CommandShortcutEvent customRedoCommand = CommandShortcutEvent(
command: 'ctrl+y,ctrl+shift+z',
macOSCommand: 'cmd+shift+z',
handler: (editorState) {
if (editorState.selection == null) {
return KeyEventResult.ignored;
}
EditorNotification.redo().post();
return KeyEventResult.handled;
},
@@ -11,6 +11,10 @@ class ApplicationInfo {
static String applicationVersion = '';
static String buildNumber = '';
static String deviceId = '';
// macOS major version
static int? macOSMajorVersion;
static int? macOSMinorVersion;
}
class ApplicationInfoTask extends LaunchTask {
@@ -21,6 +25,12 @@ class ApplicationInfoTask extends LaunchTask {
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
final PackageInfo packageInfo = await PackageInfo.fromPlatform();
if (Platform.isMacOS) {
final macInfo = await deviceInfoPlugin.macOsInfo;
ApplicationInfo.macOSMajorVersion = macInfo.majorVersion;
ApplicationInfo.macOSMinorVersion = macInfo.minorVersion;
}
if (Platform.isAndroid) {
final androidInfo = await deviceInfoPlugin.androidInfo;
ApplicationInfo.androidSDKVersion = androidInfo.version.sdkInt;
@@ -68,7 +68,7 @@ class AppFlowyCloudMockAuthService implements AuthService {
value.fold(
(l) => null,
(err) {
debugPrint("Error: $err");
debugPrint("signUpWithOAuth Error: $err");
Log.error(err);
},
);
@@ -76,7 +76,7 @@ class AppFlowyCloudMockAuthService implements AuthService {
});
},
(r) {
debugPrint("Error: $r");
debugPrint("signUpWithOAuth Error: $r");
return FlowyResult.failure(r);
},
);
@@ -1,7 +1,6 @@
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/import.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
class ImportPayload {
@@ -17,7 +16,7 @@ class ImportPayload {
}
class ImportBackendService {
static Future<FlowyResult<void, FlowyError>> importPages(
static Future<FlowyResult<RepeatedViewPB, FlowyError>> importPages(
String parentViewId,
List<ImportValuePayloadPB> values,
) async {
@@ -289,6 +289,14 @@ class UserWorkspaceBloc extends Bloc<UserWorkspaceEvent, UserWorkspaceState> {
);
},
updateWorkspaceIcon: (workspaceId, icon) async {
final workspace = state.workspaces.firstWhere(
(e) => e.workspaceId == workspaceId,
);
if (icon == workspace.icon) {
Log.info('ignore same icon update');
return;
}
final result = await _userService.updateWorkspaceIcon(
workspaceId,
icon,
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/plugins/ai_chat/chat.dart';
import 'package:appflowy/plugins/database/board/presentation/board_page.dart';
@@ -15,6 +16,7 @@ import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class PluginArgumentKeys {
@@ -297,6 +299,11 @@ extension ViewLayoutExtension on ViewLayoutPB {
ViewLayoutPB.Document || ViewLayoutPB.Chat => false,
_ => throw Exception('Unknown layout type'),
};
String get defaultName => switch (this) {
ViewLayoutPB.Document => '',
_ => LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
};
}
extension ViewFinder on List<ViewPB> {
@@ -862,13 +862,6 @@ void moveViewCrossSpace(
return;
}
final fromSection = spaceType == FolderSpaceType.public
? ViewSectionPB.Private
: ViewSectionPB.Public;
final toSection = spaceType == FolderSpaceType.public
? ViewSectionPB.Public
: ViewSectionPB.Private;
final currentSpace = context.read<SpaceBloc>().state.currentSpace;
if (currentSpace != null &&
toSpace != null &&
@@ -884,14 +877,8 @@ void moveViewCrossSpace(
from,
toId,
null,
fromSection,
toSection,
),
);
context.read<ViewBloc>().add(
ViewEvent.updateViewVisibility(
from,
spaceType == FolderSpaceType.public,
null,
null,
),
);
}
@@ -75,6 +75,13 @@ class MainFlutterWindow: NSWindow {
self.styleMask.insert(StyleMask.fullSizeContentView)
self.isMovableByWindowBackground = true
// For the macOS version 15 or higher, set it to true to enable the window tiling
if #available(macOS 15.0, *) {
self.isMovable = true
} else {
self.isMovable = false
}
self.layoutTrafficLights()
RegisterGeneratedPlugins(registry: flutterViewController)
@@ -1,4 +1,6 @@
import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/row/row_service.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -6,20 +8,25 @@ import 'package:flutter_test/flutter_test.dart';
import '../util.dart';
void main() {
late AppFlowyGridCellTest cellTest;
late AppFlowyGridTest cellTest;
setUpAll(() async {
cellTest = await AppFlowyGridCellTest.ensureInitialized();
cellTest = await AppFlowyGridTest.ensureInitialized();
});
group('SingleSelectOptionBloc', () {
test('create options', () async {
await cellTest.createTestGrid();
await cellTest.createTestRow();
final cellController = cellTest.makeSelectOptionCellController(
FieldType.SingleSelect,
0,
);
group('select cell bloc:', () {
late GridTestContext context;
late SelectOptionCellController cellController;
setUp(() async {
context = await cellTest.makeDefaultTestGrid();
await RowBackendService.createRow(viewId: context.view.id);
final fieldIndex = context.fieldController.fieldInfos
.indexWhere((field) => field.fieldType == FieldType.SingleSelect);
cellController = context.makeGridCellController(fieldIndex, 0).as();
});
test('create options', () async {
final bloc = SelectOptionCellEditorBloc(cellController: cellController);
await gridResponseFuture();
@@ -32,13 +39,6 @@ void main() {
});
test('update options', () async {
await cellTest.createTestGrid();
await cellTest.createTestRow();
final cellController = cellTest.makeSelectOptionCellController(
FieldType.SingleSelect,
0,
);
final bloc = SelectOptionCellEditorBloc(cellController: cellController);
await gridResponseFuture();
@@ -57,13 +57,6 @@ void main() {
});
test('delete options', () async {
await cellTest.createTestGrid();
await cellTest.createTestRow();
final cellController = cellTest.makeSelectOptionCellController(
FieldType.SingleSelect,
0,
);
final bloc = SelectOptionCellEditorBloc(cellController: cellController);
await gridResponseFuture();
@@ -108,13 +101,6 @@ void main() {
});
test('select/unselect option', () async {
await cellTest.createTestGrid();
await cellTest.createTestRow();
final cellController = cellTest.makeSelectOptionCellController(
FieldType.SingleSelect,
0,
);
final bloc = SelectOptionCellEditorBloc(cellController: cellController);
await gridResponseFuture();
@@ -135,13 +121,6 @@ void main() {
});
test('select an option or create one', () async {
await cellTest.createTestGrid();
await cellTest.createTestRow();
final cellController = cellTest.makeSelectOptionCellController(
FieldType.SingleSelect,
0,
);
final bloc = SelectOptionCellEditorBloc(cellController: cellController);
await gridResponseFuture();
@@ -163,13 +142,6 @@ void main() {
});
test('select multiple options', () async {
await cellTest.createTestGrid();
await cellTest.createTestRow();
final cellController = cellTest.makeSelectOptionCellController(
FieldType.SingleSelect,
0,
);
final bloc = SelectOptionCellEditorBloc(cellController: cellController);
await gridResponseFuture();
@@ -195,13 +167,6 @@ void main() {
});
test('filter options', () async {
await cellTest.createTestGrid();
await cellTest.createTestRow();
final cellController = cellTest.makeSelectOptionCellController(
FieldType.SingleSelect,
0,
);
final bloc = SelectOptionCellEditorBloc(cellController: cellController);
await gridResponseFuture();
@@ -0,0 +1,109 @@
import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/row/row_service.dart';
import 'package:appflowy/plugins/database/domain/field_service.dart';
import 'package:appflowy/plugins/database/domain/field_settings_service.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:flutter_test/flutter_test.dart';
import '../util.dart';
void main() {
late AppFlowyGridTest cellTest;
setUpAll(() async {
cellTest = await AppFlowyGridTest.ensureInitialized();
});
group('text cell bloc:', () {
late GridTestContext context;
late TextCellController cellController;
setUp(() async {
context = await cellTest.makeDefaultTestGrid();
await RowBackendService.createRow(viewId: context.view.id);
final fieldIndex = context.fieldController.fieldInfos
.indexWhere((field) => field.fieldType == FieldType.RichText);
cellController = context.makeGridCellController(fieldIndex, 0).as();
});
test('update text', () async {
final bloc = TextCellBloc(cellController: cellController);
await gridResponseFuture();
expect(bloc.state.content, "");
bloc.add(const TextCellEvent.updateText("A"));
await gridResponseFuture(milliseconds: 600);
expect(bloc.state.content, "A");
});
test('non-primary text field emoji and hasDocument', () async {
final primaryBloc = TextCellBloc(cellController: cellController);
expect(primaryBloc.state.emoji == null, false);
expect(primaryBloc.state.hasDocument == null, false);
await primaryBloc.close();
await FieldBackendService.createField(
viewId: context.view.id,
fieldName: "Second",
);
await gridResponseFuture();
final fieldIndex = context.fieldController.fieldInfos.indexWhere(
(field) => field.fieldType == FieldType.RichText && !field.isPrimary,
);
cellController = context.makeGridCellController(fieldIndex, 0).as();
final nonPrimaryBloc = TextCellBloc(cellController: cellController);
await gridResponseFuture();
expect(nonPrimaryBloc.state.emoji == null, true);
expect(nonPrimaryBloc.state.hasDocument == null, true);
});
test('update wrap cell content', () async {
final bloc = TextCellBloc(cellController: cellController);
await gridResponseFuture();
expect(bloc.state.wrap, true);
await FieldSettingsBackendService(
viewId: context.view.id,
).updateFieldSettings(
fieldId: cellController.fieldId,
wrapCellContent: false,
);
await gridResponseFuture();
expect(bloc.state.wrap, false);
});
test('update emoji', () async {
final bloc = TextCellBloc(cellController: cellController);
await gridResponseFuture();
expect(bloc.state.emoji!.value, "");
await RowBackendService(viewId: context.view.id)
.updateMeta(rowId: cellController.rowId, iconURL: "dummy");
await gridResponseFuture();
expect(bloc.state.emoji!.value, "dummy");
});
test('update document data', () async {
// This is so fake?
final bloc = TextCellBloc(cellController: cellController);
await gridResponseFuture();
expect(bloc.state.hasDocument!.value, false);
await RowBackendService(viewId: context.view.id)
.updateMeta(rowId: cellController.rowId, isDocumentEmpty: false);
await gridResponseFuture();
expect(bloc.state.hasDocument!.value, true);
});
});
}
@@ -1,43 +0,0 @@
import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:flutter_test/flutter_test.dart';
import '../util.dart';
Future<FieldEditorBloc> createEditorBloc(AppFlowyGridTest gridTest) async {
final context = await gridTest.createTestGrid();
final fieldInfo = context.getSelectOptionField();
return FieldEditorBloc(
viewId: context.gridView.id,
fieldController: context.fieldController,
fieldInfo: fieldInfo,
isNew: false,
);
}
void main() {
late AppFlowyGridTest gridTest;
setUpAll(() async {
gridTest = await AppFlowyGridTest.ensureInitialized();
});
test('rename field', () async {
final editorBloc = await createEditorBloc(gridTest);
editorBloc.add(const FieldEditorEvent.renameField('Hello world'));
await gridResponseFuture();
expect(editorBloc.state.field.name, equals("Hello world"));
});
test('switch to text field', () async {
final editorBloc = await createEditorBloc(gridTest);
editorBloc.add(const FieldEditorEvent.switchFieldType(FieldType.RichText));
await gridResponseFuture();
// The default length of the fields is 3. The length of the fields
// should not change after switching to other field type
expect(editorBloc.state.field.fieldType, equals(FieldType.RichText));
});
}
@@ -11,19 +11,19 @@ void main() {
gridTest = await AppFlowyGridTest.ensureInitialized();
});
group('$FieldCellBloc', () {
group('field cell bloc:', () {
late GridTestContext context;
late double width;
setUp(() async {
context = await gridTest.createTestGrid();
context = await gridTest.makeDefaultTestGrid();
});
blocTest(
'update field width',
build: () => FieldCellBloc(
fieldInfo: context.fieldInfos[0],
viewId: context.gridView.id,
fieldInfo: context.fieldController.fieldInfos[0],
viewId: context.view.id,
),
act: (bloc) {
width = bloc.state.width;
@@ -37,10 +37,10 @@ void main() {
);
blocTest(
'field width should not be lesser than 50px',
'field width should not be less than 50px',
build: () => FieldCellBloc(
viewId: context.gridView.id,
fieldInfo: context.fieldInfos[0],
viewId: context.view.id,
fieldInfo: context.fieldController.fieldInfos[0],
),
act: (bloc) {
bloc.add(const FieldCellEvent.onResizeStart());
@@ -0,0 +1,133 @@
import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart';
import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nanoid/nanoid.dart';
import '../util.dart';
void main() {
late AppFlowyGridTest gridTest;
setUpAll(() async {
gridTest = await AppFlowyGridTest.ensureInitialized();
});
group('field editor bloc:', () {
late GridTestContext context;
late FieldEditorBloc editorBloc;
setUp(() async {
context = await gridTest.makeDefaultTestGrid();
final fieldInfo = context.fieldController.fieldInfos
.firstWhere((field) => field.fieldType == FieldType.SingleSelect);
editorBloc = FieldEditorBloc(
viewId: context.view.id,
fieldController: context.fieldController,
fieldInfo: fieldInfo,
isNew: false,
);
});
test('rename field', () async {
expect(editorBloc.state.field.name, equals("Type"));
editorBloc.add(const FieldEditorEvent.renameField('Hello world'));
await gridResponseFuture();
expect(editorBloc.state.field.name, equals("Hello world"));
});
test('edit icon', () async {
expect(editorBloc.state.field.icon, equals(""));
editorBloc.add(const FieldEditorEvent.updateIcon('emoji/smiley-face'));
await gridResponseFuture();
expect(editorBloc.state.field.icon, equals("emoji/smiley-face"));
editorBloc.add(const FieldEditorEvent.updateIcon(""));
await gridResponseFuture();
expect(editorBloc.state.field.icon, equals(""));
});
test('switch to text field', () async {
expect(editorBloc.state.field.fieldType, equals(FieldType.SingleSelect));
editorBloc.add(
const FieldEditorEvent.switchFieldType(FieldType.RichText),
);
await gridResponseFuture();
expect(editorBloc.state.field.fieldType, equals(FieldType.RichText));
});
test('update field type option', () async {
final selectOption = SelectOptionPB()
..id = nanoid(4)
..color = SelectOptionColorPB.Lime
..name = "New option";
final typeOptionData = SingleSelectTypeOptionPB()
..options.addAll([selectOption]);
editorBloc.add(
FieldEditorEvent.updateTypeOption(typeOptionData.writeToBuffer()),
);
await gridResponseFuture();
final actual = SingleSelectTypeOptionDataParser()
.fromBuffer(editorBloc.state.field.field.typeOptionData);
expect(actual, equals(typeOptionData));
});
test('update visibility', () async {
expect(
editorBloc.state.field.visibility,
equals(FieldVisibility.AlwaysShown),
);
editorBloc.add(const FieldEditorEvent.toggleFieldVisibility());
await gridResponseFuture();
expect(
editorBloc.state.field.visibility,
equals(FieldVisibility.AlwaysHidden),
);
});
test('update wrap cell', () async {
expect(
editorBloc.state.field.wrapCellContent,
equals(true),
);
editorBloc.add(const FieldEditorEvent.toggleWrapCellContent());
await gridResponseFuture();
expect(
editorBloc.state.field.wrapCellContent,
equals(false),
);
});
test('insert left and right', () async {
expect(
context.fieldController.fieldInfos.length,
equals(3),
);
editorBloc.add(const FieldEditorEvent.insertLeft());
// TODO(RS): Shouldn't need to wait here!?
await gridResponseFuture();
editorBloc.add(const FieldEditorEvent.insertRight());
await gridResponseFuture();
expect(
context.fieldController.fieldInfos.length,
equals(5),
);
});
});
}
@@ -1,159 +0,0 @@
import 'package:appflowy/plugins/database/domain/filter_service.dart';
import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/checkbox_filter.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/text_filter.pb.dart';
import 'package:flutter_test/flutter_test.dart';
import '../util.dart';
void main() {
late AppFlowyGridTest gridTest;
setUpAll(() async {
gridTest = await AppFlowyGridTest.ensureInitialized();
});
test('create a text filter)', () async {
final context = await gridTest.createTestGrid();
final service = FilterBackendService(viewId: context.gridView.id);
final textField = context.getTextField();
await service.insertTextFilter(
fieldId: textField.id,
condition: TextFilterConditionPB.TextIsEmpty,
content: "",
);
await gridResponseFuture();
assert(context.fieldController.filters.length == 1);
});
test('delete a text filter)', () async {
final context = await gridTest.createTestGrid();
final service = FilterBackendService(viewId: context.gridView.id);
final textField = context.getTextField();
await service.insertTextFilter(
fieldId: textField.id,
condition: TextFilterConditionPB.TextIsEmpty,
content: "",
);
await gridResponseFuture();
final filter = context.fieldController.filters.first;
await service.deleteFilter(
filterId: filter.filterId,
);
await gridResponseFuture();
expect(context.fieldController.filters.length, 0);
});
test('filter rows with condition: text is empty', () async {
final context = await gridTest.createTestGrid();
final service = FilterBackendService(viewId: context.gridView.id);
final gridController = DatabaseController(
view: context.gridView,
);
final gridBloc = GridBloc(
view: context.gridView,
databaseController: gridController,
)..add(const GridEvent.initial());
await gridResponseFuture();
final textField = context.getTextField();
await service.insertTextFilter(
fieldId: textField.id,
condition: TextFilterConditionPB.TextIsEmpty,
content: "",
);
await gridResponseFuture();
expect(gridBloc.state.rowInfos.length, 3);
});
test('filter rows with condition: text is empty(After edit the row)',
() async {
final context = await gridTest.createTestGrid();
final service = FilterBackendService(viewId: context.gridView.id);
final gridController = DatabaseController(
view: context.gridView,
);
final gridBloc = GridBloc(
view: context.gridView,
databaseController: gridController,
)..add(const GridEvent.initial());
await gridResponseFuture();
final textField = context.getTextField();
await service.insertTextFilter(
fieldId: textField.id,
condition: TextFilterConditionPB.TextIsEmpty,
content: "",
);
await gridResponseFuture();
final controller = context.makeTextCellController(0);
await controller.saveCellData("edit text cell content");
await gridResponseFuture();
assert(gridBloc.state.rowInfos.length == 2);
await controller.saveCellData("");
await gridResponseFuture();
assert(gridBloc.state.rowInfos.length == 3);
});
test('filter rows with condition: text is not empty', () async {
final context = await gridTest.createTestGrid();
final service = FilterBackendService(viewId: context.gridView.id);
final textField = context.getTextField();
await gridResponseFuture();
await service.insertTextFilter(
fieldId: textField.id,
condition: TextFilterConditionPB.TextIsNotEmpty,
content: "",
);
await gridResponseFuture();
assert(context.rowInfos.isEmpty);
});
test('filter rows with condition: checkbox uncheck', () async {
final context = await gridTest.createTestGrid();
final checkboxField = context.getCheckboxField();
final service = FilterBackendService(viewId: context.gridView.id);
final gridController = DatabaseController(
view: context.gridView,
);
final gridBloc = GridBloc(
view: context.gridView,
databaseController: gridController,
)..add(const GridEvent.initial());
await gridResponseFuture();
await service.insertCheckboxFilter(
fieldId: checkboxField.id,
condition: CheckboxFilterConditionPB.IsUnChecked,
);
await gridResponseFuture();
assert(gridBloc.state.rowInfos.length == 3);
});
test('filter rows with condition: checkbox check', () async {
final context = await gridTest.createTestGrid();
final checkboxField = context.getCheckboxField();
final service = FilterBackendService(viewId: context.gridView.id);
final gridController = DatabaseController(
view: context.gridView,
);
final gridBloc = GridBloc(
view: context.gridView,
databaseController: gridController,
)..add(const GridEvent.initial());
await gridResponseFuture();
await service.insertCheckboxFilter(
fieldId: checkboxField.id,
condition: CheckboxFilterConditionPB.IsChecked,
);
await gridResponseFuture();
assert(gridBloc.state.rowInfos.isEmpty);
});
}
@@ -1,3 +1,4 @@
import 'package:appflowy/plugins/database/application/field/field_info.dart';
import 'package:appflowy/plugins/database/application/field/filter_entities.dart';
import 'package:appflowy/plugins/database/domain/field_service.dart';
import 'package:appflowy/plugins/database/domain/filter_service.dart';
@@ -9,24 +10,35 @@ import '../util.dart';
void main() {
late AppFlowyGridTest gridTest;
setUpAll(() async {
gridTest = await AppFlowyGridTest.ensureInitialized();
});
group('filter editor bloc test:', () {
test('create filter', () async {
final context = await gridTest.createTestGrid();
final filterBloc = FilterEditorBloc(
viewId: context.gridView.id,
group('filter editor bloc:', () {
late GridTestContext context;
late FilterEditorBloc filterBloc;
setUp(() async {
context = await gridTest.makeDefaultTestGrid();
filterBloc = FilterEditorBloc(
viewId: context.view.id,
fieldController: context.fieldController,
);
await gridResponseFuture();
});
FieldInfo getFirstFieldByType(FieldType fieldType) {
return context.fieldController.fieldInfos
.firstWhere((field) => field.fieldType == fieldType);
}
test('create filter', () async {
expect(filterBloc.state.filters.length, equals(0));
expect(filterBloc.state.fields.length, equals(3));
// through domain directly
final textField = context.getTextField();
final service = FilterBackendService(viewId: context.gridView.id);
final textField = getFirstFieldByType(FieldType.RichText);
final service = FilterBackendService(viewId: context.view.id);
await service.insertTextFilter(
fieldId: textField.id,
condition: TextFilterConditionPB.TextIsEmpty,
@@ -37,7 +49,7 @@ void main() {
expect(filterBloc.state.fields.length, equals(3));
// through bloc event
final selectOptionField = context.getSelectOptionField();
final selectOptionField = getFirstFieldByType(FieldType.SingleSelect);
filterBloc.add(FilterEditorEvent.createFilter(selectOptionField));
await gridResponseFuture();
expect(filterBloc.state.filters.length, equals(2));
@@ -53,15 +65,33 @@ void main() {
expect(filterBloc.state.fields.length, equals(3));
});
test('delete filter', () async {
final context = await gridTest.createTestGrid();
final filterBloc = FilterEditorBloc(
viewId: context.gridView.id,
fieldController: context.fieldController,
test('change filtering field', () async {
final textField = getFirstFieldByType(FieldType.RichText);
final selectField = getFirstFieldByType(FieldType.Checkbox);
filterBloc.add(FilterEditorEvent.createFilter(textField));
await gridResponseFuture();
expect(filterBloc.state.filters.length, equals(1));
expect(filterBloc.state.fields.length, equals(3));
expect(
filterBloc.state.filters.first.fieldType,
equals(FieldType.RichText),
);
final filter = filterBloc.state.filters.first;
filterBloc.add(
FilterEditorEvent.changeFilteringField(filter.filterId, selectField),
);
await gridResponseFuture();
expect(filterBloc.state.filters.length, equals(1));
expect(
filterBloc.state.filters.first.fieldType,
equals(FieldType.Checkbox),
);
expect(filterBloc.state.fields.length, equals(3));
});
final textField = context.getTextField();
test('delete filter', () async {
final textField = getFirstFieldByType(FieldType.RichText);
filterBloc.add(FilterEditorEvent.createFilter(textField));
await gridResponseFuture();
expect(filterBloc.state.filters.length, equals(1));
@@ -75,15 +105,8 @@ void main() {
});
test('update filter', () async {
final context = await gridTest.createTestGrid();
final filterBloc = FilterEditorBloc(
viewId: context.gridView.id,
fieldController: context.fieldController,
);
await gridResponseFuture();
final service = FilterBackendService(viewId: context.gridView.id);
final textField = context.getTextField();
final service = FilterBackendService(viewId: context.view.id);
final textField = getFirstFieldByType(FieldType.RichText);
// Create filter
await service.insertTextFilter(
@@ -96,6 +119,7 @@ void main() {
expect(filter.condition, equals(TextFilterConditionPB.TextIsEmpty));
final textFilter = context.fieldController.filters.first;
// Update the existing filter
await service.insertTextFilter(
fieldId: textField.id,
@@ -109,15 +133,8 @@ void main() {
expect(filter.content, equals("ABC"));
});
test('update field', () async {
final context = await gridTest.createTestGrid();
final filterBloc = FilterEditorBloc(
viewId: context.gridView.id,
fieldController: context.fieldController,
);
await gridResponseFuture();
final textField = context.getTextField();
test('update filtering field\'s name', () async {
final textField = getFirstFieldByType(FieldType.RichText);
filterBloc.add(FilterEditorEvent.createFilter(textField));
await gridResponseFuture();
expect(filterBloc.state.filters.length, equals(1));
@@ -127,7 +144,7 @@ void main() {
// edit field
await FieldBackendService(
viewId: context.gridView.id,
viewId: context.view.id,
fieldId: textField.id,
).updateField(name: "New Name");
await gridResponseFuture();
@@ -136,28 +153,40 @@ void main() {
});
test('update field type', () async {
final context = await gridTest.createTestGrid();
final filterBloc = FilterEditorBloc(
viewId: context.gridView.id,
fieldController: context.fieldController,
);
await gridResponseFuture();
final checkboxField = context.getCheckboxField();
final checkboxField = getFirstFieldByType(FieldType.Checkbox);
filterBloc.add(FilterEditorEvent.createFilter(checkboxField));
await gridResponseFuture();
expect(filterBloc.state.filters.length, equals(1));
// edit field
await FieldBackendService(
viewId: context.gridView.id,
viewId: context.view.id,
fieldId: checkboxField.id,
).updateType(fieldType: FieldType.DateTime);
await gridResponseFuture();
expect(filterBloc.state.filters.length, equals(0));
// filter is removed
expect(filterBloc.state.filters.length, equals(0));
expect(filterBloc.state.fields.length, equals(3));
expect(filterBloc.state.fields[2].fieldType, FieldType.DateTime);
});
test('update filter field', () async {
final checkboxField = getFirstFieldByType(FieldType.Checkbox);
filterBloc.add(FilterEditorEvent.createFilter(checkboxField));
await gridResponseFuture();
expect(filterBloc.state.filters.length, equals(1));
// edit field
await FieldBackendService(
viewId: context.view.id,
fieldId: checkboxField.id,
).updateField(name: "HERRO");
await gridResponseFuture();
expect(filterBloc.state.filters.length, equals(1));
expect(filterBloc.state.fields.length, equals(3));
expect(filterBloc.state.fields[2].name, "HERRO");
});
});
}
@@ -0,0 +1,623 @@
import 'dart:typed_data';
import 'package:appflowy/plugins/database/application/field/filter_entities.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('parsing filter entities:', () {
FilterPB createFilterPB(
FieldType fieldType,
Uint8List data,
) {
return FilterPB(
id: "FT",
filterType: FilterType.Data,
data: FilterDataPB(
fieldId: "FD",
fieldType: fieldType,
data: data,
),
);
}
test('text', () async {
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.RichText,
TextFilterPB(
condition: TextFilterConditionPB.TextContains,
content: "c",
).writeToBuffer(),
),
),
equals(
TextFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.RichText,
condition: TextFilterConditionPB.TextContains,
content: "c",
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.RichText,
TextFilterPB(
condition: TextFilterConditionPB.TextContains,
content: "",
).writeToBuffer(),
),
),
equals(
TextFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.RichText,
condition: TextFilterConditionPB.TextContains,
content: "",
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.RichText,
TextFilterPB(
condition: TextFilterConditionPB.TextIsEmpty,
content: "",
).writeToBuffer(),
),
),
equals(
TextFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.RichText,
condition: TextFilterConditionPB.TextIsEmpty,
content: "",
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.RichText,
TextFilterPB(
condition: TextFilterConditionPB.TextIsEmpty,
content: "",
).writeToBuffer(),
),
),
equals(
TextFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.RichText,
condition: TextFilterConditionPB.TextIsEmpty,
content: "c",
),
),
);
});
test('number', () async {
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.Number,
NumberFilterPB(
condition: NumberFilterConditionPB.GreaterThan,
content: "",
).writeToBuffer(),
),
),
equals(
NumberFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.Number,
condition: NumberFilterConditionPB.GreaterThan,
content: "",
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.Number,
NumberFilterPB(
condition: NumberFilterConditionPB.GreaterThan,
content: "123",
).writeToBuffer(),
),
),
equals(
NumberFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.Number,
condition: NumberFilterConditionPB.GreaterThan,
content: "123",
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.Number,
NumberFilterPB(
condition: NumberFilterConditionPB.NumberIsEmpty,
content: "",
).writeToBuffer(),
),
),
equals(
NumberFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.Number,
condition: NumberFilterConditionPB.NumberIsEmpty,
content: "",
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.Number,
NumberFilterPB(
condition: NumberFilterConditionPB.NumberIsEmpty,
content: "",
).writeToBuffer(),
),
),
equals(
NumberFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.Number,
condition: NumberFilterConditionPB.NumberIsEmpty,
content: "123",
),
),
);
});
test('checkbox', () async {
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.Checkbox,
CheckboxFilterPB(
condition: CheckboxFilterConditionPB.IsChecked,
).writeToBuffer(),
),
),
equals(
const CheckboxFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.Checkbox,
condition: CheckboxFilterConditionPB.IsChecked,
),
),
);
});
test('checklist', () async {
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.Checklist,
ChecklistFilterPB(
condition: ChecklistFilterConditionPB.IsComplete,
).writeToBuffer(),
),
),
equals(
const ChecklistFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.Checklist,
condition: ChecklistFilterConditionPB.IsComplete,
),
),
);
});
test('single select option', () async {
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.SingleSelect,
SelectOptionFilterPB(
condition: SelectOptionFilterConditionPB.OptionContains,
optionIds: [],
).writeToBuffer(),
),
),
equals(
SelectOptionFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.SingleSelect,
condition: SelectOptionFilterConditionPB.OptionContains,
optionIds: const [],
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.SingleSelect,
SelectOptionFilterPB(
condition: SelectOptionFilterConditionPB.OptionContains,
optionIds: ['a'],
).writeToBuffer(),
),
),
equals(
SelectOptionFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.SingleSelect,
condition: SelectOptionFilterConditionPB.OptionContains,
optionIds: const ['a'],
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.SingleSelect,
SelectOptionFilterPB(
condition: SelectOptionFilterConditionPB.OptionContains,
optionIds: ['a', 'b'],
).writeToBuffer(),
),
),
equals(
SelectOptionFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.SingleSelect,
condition: SelectOptionFilterConditionPB.OptionContains,
optionIds: const ['a', 'b'],
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.SingleSelect,
SelectOptionFilterPB(
condition: SelectOptionFilterConditionPB.OptionIs,
optionIds: ['a', 'b'],
).writeToBuffer(),
),
),
equals(
SelectOptionFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.SingleSelect,
condition: SelectOptionFilterConditionPB.OptionIs,
optionIds: const ['a'],
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.SingleSelect,
SelectOptionFilterPB(
condition: SelectOptionFilterConditionPB.OptionIsEmpty,
optionIds: [],
).writeToBuffer(),
),
),
equals(
SelectOptionFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.SingleSelect,
condition: SelectOptionFilterConditionPB.OptionIsEmpty,
optionIds: const [],
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.SingleSelect,
SelectOptionFilterPB(
condition: SelectOptionFilterConditionPB.OptionIsEmpty,
optionIds: ['a'],
).writeToBuffer(),
),
),
equals(
SelectOptionFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.SingleSelect,
condition: SelectOptionFilterConditionPB.OptionIsEmpty,
optionIds: const [],
),
),
);
});
test('multi select option', () async {
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.MultiSelect,
SelectOptionFilterPB(
condition: SelectOptionFilterConditionPB.OptionContains,
optionIds: [],
).writeToBuffer(),
),
),
equals(
SelectOptionFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.MultiSelect,
condition: SelectOptionFilterConditionPB.OptionContains,
optionIds: const [],
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.MultiSelect,
SelectOptionFilterPB(
condition: SelectOptionFilterConditionPB.OptionContains,
optionIds: ['a'],
).writeToBuffer(),
),
),
equals(
SelectOptionFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.MultiSelect,
condition: SelectOptionFilterConditionPB.OptionContains,
optionIds: const ['a'],
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.MultiSelect,
SelectOptionFilterPB(
condition: SelectOptionFilterConditionPB.OptionContains,
optionIds: ['a', 'b'],
).writeToBuffer(),
),
),
equals(
SelectOptionFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.MultiSelect,
condition: SelectOptionFilterConditionPB.OptionContains,
optionIds: const ['a', 'b'],
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.MultiSelect,
SelectOptionFilterPB(
condition: SelectOptionFilterConditionPB.OptionIs,
optionIds: ['a', 'b'],
).writeToBuffer(),
),
),
equals(
SelectOptionFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.MultiSelect,
condition: SelectOptionFilterConditionPB.OptionIs,
optionIds: const ['a', 'b'],
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.MultiSelect,
SelectOptionFilterPB(
condition: SelectOptionFilterConditionPB.OptionIsEmpty,
optionIds: [],
).writeToBuffer(),
),
),
equals(
SelectOptionFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.MultiSelect,
condition: SelectOptionFilterConditionPB.OptionIsEmpty,
optionIds: const [],
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.MultiSelect,
SelectOptionFilterPB(
condition: SelectOptionFilterConditionPB.OptionIsEmpty,
optionIds: ['a'],
).writeToBuffer(),
),
),
equals(
SelectOptionFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.MultiSelect,
condition: SelectOptionFilterConditionPB.OptionIsEmpty,
optionIds: const [],
),
),
);
});
test('date time', () {
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.DateTime,
DateFilterPB(
condition: DateFilterConditionPB.DateStartsOn,
timestamp: Int64(5),
).writeToBuffer(),
),
),
equals(
DateTimeFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.DateTime,
condition: DateFilterConditionPB.DateStartsOn,
timestamp: DateTime.fromMillisecondsSinceEpoch(5 * 1000),
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.DateTime,
DateFilterPB(
condition: DateFilterConditionPB.DateStartsOn,
).writeToBuffer(),
),
),
equals(
DateTimeFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.DateTime,
condition: DateFilterConditionPB.DateStartsOn,
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.DateTime,
DateFilterPB(
condition: DateFilterConditionPB.DateStartsOn,
start: Int64(5),
).writeToBuffer(),
),
),
equals(
DateTimeFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.DateTime,
condition: DateFilterConditionPB.DateStartsOn,
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.DateTime,
DateFilterPB(
condition: DateFilterConditionPB.DateEndsBetween,
start: Int64(5),
).writeToBuffer(),
),
),
equals(
DateTimeFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.DateTime,
condition: DateFilterConditionPB.DateEndsBetween,
start: DateTime.fromMillisecondsSinceEpoch(5 * 1000),
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.DateTime,
DateFilterPB(
condition: DateFilterConditionPB.DateEndIsNotEmpty,
).writeToBuffer(),
),
),
equals(
DateTimeFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.DateTime,
condition: DateFilterConditionPB.DateEndIsNotEmpty,
),
),
);
expect(
DatabaseFilter.fromPB(
createFilterPB(
FieldType.DateTime,
DateFilterPB(
condition: DateFilterConditionPB.DateEndIsNotEmpty,
start: Int64(5),
end: Int64(5),
timestamp: Int64(5),
).writeToBuffer(),
),
),
equals(
DateTimeFilter(
filterId: "FT",
fieldId: "FD",
fieldType: FieldType.DateTime,
condition: DateFilterConditionPB.DateEndIsNotEmpty,
),
),
);
});
});
// group('write to buffer', () {
// test('text', () {});
// });
}
@@ -1,55 +0,0 @@
import 'package:appflowy/plugins/database/domain/filter_service.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/checkbox_filter.pbenum.dart';
import 'package:flutter_test/flutter_test.dart';
import '../util.dart';
import 'filter_util.dart';
void main() {
late AppFlowyGridTest gridTest;
setUpAll(() async {
gridTest = await AppFlowyGridTest.ensureInitialized();
});
test('filter rows by checkbox is check condition)', () async {
final context = await createTestFilterGrid(gridTest);
final service = FilterBackendService(viewId: context.gridView.id);
final controller = context.makeCheckboxCellController(0);
await controller.saveCellData("Yes");
await gridResponseFuture();
// create a new filter
final checkboxField = context.getCheckboxField();
await service.insertCheckboxFilter(
fieldId: checkboxField.id,
condition: CheckboxFilterConditionPB.IsChecked,
);
await gridResponseFuture();
assert(
context.rowInfos.length == 1,
"expect 1 but receive ${context.rowInfos.length}",
);
});
test('filter rows by checkbox is uncheck condition)', () async {
final context = await createTestFilterGrid(gridTest);
final service = FilterBackendService(viewId: context.gridView.id);
final controller = context.makeCheckboxCellController(0);
await controller.saveCellData("Yes");
await gridResponseFuture();
// create a new filter
final checkboxField = context.getCheckboxField();
await service.insertCheckboxFilter(
fieldId: checkboxField.id,
condition: CheckboxFilterConditionPB.IsUnChecked,
);
await gridResponseFuture();
assert(
context.rowInfos.length == 2,
"expect 2 but receive ${context.rowInfos.length}",
);
});
}
@@ -1,165 +0,0 @@
import 'package:appflowy/plugins/database/domain/filter_service.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/text_filter.pb.dart';
import 'package:flutter_test/flutter_test.dart';
import '../util.dart';
import 'filter_util.dart';
void main() {
late AppFlowyGridTest gridTest;
setUpAll(() async {
gridTest = await AppFlowyGridTest.ensureInitialized();
});
test('filter rows by text is empty condition)', () async {
final context = await createTestFilterGrid(gridTest);
final service = FilterBackendService(viewId: context.gridView.id);
final textField = context.getTextField();
// create a new filter
await service.insertTextFilter(
fieldId: textField.id,
condition: TextFilterConditionPB.TextIsEmpty,
content: "",
);
await gridResponseFuture();
assert(
context.fieldController.filters.length == 1,
"expect 1 but receive ${context.fieldController.filters.length}",
);
assert(
context.rowInfos.length == 1,
"expect 1 but receive ${context.rowInfos.length}",
);
// delete the filter
final textFilter = context.fieldController.filters.first;
await service.deleteFilter(
filterId: textFilter.filterId,
);
await gridResponseFuture();
assert(context.rowInfos.length == 3);
});
test('filter rows by text is not empty condition)', () async {
final context = await createTestFilterGrid(gridTest);
final service = FilterBackendService(viewId: context.gridView.id);
final textField = context.getTextField();
// create a new filter
await service.insertTextFilter(
fieldId: textField.id,
condition: TextFilterConditionPB.TextIsNotEmpty,
content: "",
);
await gridResponseFuture();
assert(
context.rowInfos.length == 2,
"expect 2 but receive ${context.rowInfos.length}",
);
// delete the filter
final textFilter = context.fieldController.filters.first;
await service.deleteFilter(
filterId: textFilter.filterId,
);
await gridResponseFuture();
assert(context.rowInfos.length == 3);
});
test('filter rows by text is empty or is not empty condition)', () async {
final context = await createTestFilterGrid(gridTest);
final service = FilterBackendService(viewId: context.gridView.id);
final textField = context.getTextField();
// create a new filter
await service.insertTextFilter(
fieldId: textField.id,
condition: TextFilterConditionPB.TextIsEmpty,
content: "",
);
await gridResponseFuture();
assert(
context.fieldController.filters.length == 1,
"expect 1 but receive ${context.fieldController.filters.length}",
);
assert(
context.rowInfos.length == 1,
"expect 1 but receive ${context.rowInfos.length}",
);
// Update the existing filter
final textFilter = context.fieldController.filters.first;
await service.insertTextFilter(
fieldId: textField.id,
filterId: textFilter.filterId,
condition: TextFilterConditionPB.TextIsNotEmpty,
content: "",
);
await gridResponseFuture();
assert(context.rowInfos.length == 2);
// delete the filter
await service.deleteFilter(
filterId: textFilter.filterId,
);
await gridResponseFuture();
assert(context.rowInfos.length == 3);
});
test('filter rows by text is condition)', () async {
final context = await createTestFilterGrid(gridTest);
final service = FilterBackendService(viewId: context.gridView.id);
final textField = context.getTextField();
// create a new filter
await service.insertTextFilter(
fieldId: textField.id,
condition: TextFilterConditionPB.TextIs,
content: "A",
);
await gridResponseFuture();
assert(
context.rowInfos.length == 1,
"expect 1 but receive ${context.rowInfos.length}",
);
// Update the existing filter's content from 'A' to 'B'
final textFilter = context.fieldController.filters.first;
await service.insertTextFilter(
fieldId: textField.id,
filterId: textFilter.filterId,
condition: TextFilterConditionPB.TextIs,
content: "B",
);
await gridResponseFuture();
assert(context.rowInfos.length == 1);
// Update the existing filter's content from 'B' to 'b'
await service.insertTextFilter(
fieldId: textField.id,
filterId: textFilter.filterId,
condition: TextFilterConditionPB.TextIs,
content: "b",
);
await gridResponseFuture();
assert(context.rowInfos.length == 1);
// Update the existing filter with content 'C'
await service.insertTextFilter(
fieldId: textField.id,
filterId: textFilter.filterId,
condition: TextFilterConditionPB.TextIs,
content: "C",
);
await gridResponseFuture();
assert(context.rowInfos.isEmpty);
// delete the filter
await service.deleteFilter(
filterId: textFilter.filterId,
);
await gridResponseFuture();
assert(context.rowInfos.length == 3);
});
}
@@ -1,42 +0,0 @@
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import '../util.dart';
Future<GridTestContext> createTestFilterGrid(AppFlowyGridTest gridTest) async {
final app = await gridTest.unitTest.createWorkspace();
final context = await ViewBackendService.createView(
parentViewId: app.id,
name: "Filter Grid",
layoutType: ViewLayoutPB.Grid,
openAfterCreate: true,
).then((result) {
return result.fold(
(view) async {
final context = GridTestContext(
view,
DatabaseController(view: view),
);
final result = await context.gridController.open();
await editCells(context);
result.fold((l) => null, (r) => throw Exception(r));
return context;
},
(error) => throw Exception(),
);
});
return context;
}
Future<void> editCells(GridTestContext context) async {
final controller0 = context.makeTextCellController(0);
final controller1 = context.makeTextCellController(1);
await controller0.saveCellData('A');
await gridResponseFuture();
await controller1.saveCellData('B');
await gridResponseFuture();
}
@@ -7,6 +7,7 @@ import 'util.dart';
void main() {
late AppFlowyGridTest gridTest;
setUpAll(() async {
gridTest = await AppFlowyGridTest.ensureInitialized();
});
@@ -15,7 +16,7 @@ void main() {
late GridTestContext context;
setUp(() async {
context = await gridTest.createTestGrid();
context = await gridTest.makeDefaultTestGrid();
});
// The initial number of rows is 3 for each grid
@@ -23,32 +24,29 @@ void main() {
blocTest<GridBloc, GridState>(
"create a row",
build: () => GridBloc(
view: context.gridView,
databaseController: DatabaseController(view: context.gridView),
view: context.view,
databaseController: DatabaseController(view: context.view),
)..add(const GridEvent.initial()),
act: (bloc) => bloc.add(const GridEvent.createRow()),
wait: const Duration(milliseconds: 300),
wait: gridResponseDuration(),
verify: (bloc) {
assert(bloc.state.rowInfos.length == 4);
expect(bloc.state.rowInfos.length, equals(4));
},
);
blocTest<GridBloc, GridState>(
"delete the last row",
build: () => GridBloc(
view: context.gridView,
databaseController: DatabaseController(view: context.gridView),
view: context.view,
databaseController: DatabaseController(view: context.view),
)..add(const GridEvent.initial()),
act: (bloc) async {
await gridResponseFuture();
bloc.add(GridEvent.deleteRow(bloc.state.rowInfos.last));
},
wait: const Duration(milliseconds: 300),
wait: gridResponseDuration(),
verify: (bloc) {
assert(
bloc.state.rowInfos.length == 2,
"Expected 2, but receive ${bloc.state.rowInfos.length}",
);
expect(bloc.state.rowInfos.length, equals(2));
},
);
@@ -59,8 +57,8 @@ void main() {
blocTest(
'reorder rows',
build: () => GridBloc(
view: context.gridView,
databaseController: DatabaseController(view: context.gridView),
view: context.view,
databaseController: DatabaseController(view: context.view),
)..add(const GridEvent.initial()),
act: (bloc) async {
await gridResponseFuture();
@@ -71,10 +69,11 @@ void main() {
bloc.add(const GridEvent.moveRow(0, 2));
},
wait: gridResponseDuration(),
verify: (bloc) {
expect(secondId, bloc.state.rowInfos[0].rowId);
expect(thirdId, bloc.state.rowInfos[1].rowId);
expect(firstId, bloc.state.rowInfos[2].rowId);
expect(secondId, equals(bloc.state.rowInfos[0].rowId));
expect(thirdId, equals(bloc.state.rowInfos[1].rowId));
expect(firstId, equals(bloc.state.rowInfos[2].rowId));
},
);
});
@@ -0,0 +1,204 @@
import 'package:appflowy/plugins/database/application/field/field_info.dart';
import 'package:appflowy/plugins/database/domain/field_service.dart';
import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:flutter_test/flutter_test.dart';
import '../util.dart';
void main() {
late AppFlowyGridTest gridTest;
setUpAll(() async {
gridTest = await AppFlowyGridTest.ensureInitialized();
});
group('sort editor bloc:', () {
late GridTestContext context;
late SortEditorBloc sortBloc;
setUp(() async {
context = await gridTest.makeDefaultTestGrid();
sortBloc = SortEditorBloc(
viewId: context.view.id,
fieldController: context.fieldController,
);
});
FieldInfo getFirstFieldByType(FieldType fieldType) {
return context.fieldController.fieldInfos
.firstWhere((field) => field.fieldType == fieldType);
}
test('create sort', () async {
expect(sortBloc.state.sorts.length, equals(0));
expect(sortBloc.state.creatableFields.length, equals(3));
expect(sortBloc.state.allFields.length, equals(3));
final selectOptionField = getFirstFieldByType(FieldType.SingleSelect);
sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id));
await gridResponseFuture();
expect(sortBloc.state.sorts.length, 1);
expect(sortBloc.state.sorts.first.fieldId, selectOptionField.id);
expect(
sortBloc.state.sorts.first.condition,
SortConditionPB.Ascending,
);
expect(sortBloc.state.creatableFields.length, equals(2));
expect(sortBloc.state.allFields.length, equals(3));
});
test('change sort field', () async {
final selectOptionField = getFirstFieldByType(FieldType.SingleSelect);
sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id));
await gridResponseFuture();
expect(
sortBloc.state.creatableFields
.map((e) => e.id)
.contains(selectOptionField.id),
false,
);
final checkboxField = getFirstFieldByType(FieldType.Checkbox);
sortBloc.add(
SortEditorEvent.editSort(
sortId: sortBloc.state.sorts.first.sortId,
fieldId: checkboxField.id,
),
);
await gridResponseFuture();
expect(sortBloc.state.creatableFields.length, equals(2));
expect(
sortBloc.state.creatableFields
.map((e) => e.id)
.contains(checkboxField.id),
false,
);
});
test('update sort direction', () async {
final selectOptionField = getFirstFieldByType(FieldType.SingleSelect);
sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id));
await gridResponseFuture();
expect(
sortBloc.state.sorts.first.condition,
SortConditionPB.Ascending,
);
sortBloc.add(
SortEditorEvent.editSort(
sortId: sortBloc.state.sorts.first.sortId,
condition: SortConditionPB.Descending,
),
);
await gridResponseFuture();
expect(
sortBloc.state.sorts.first.condition,
SortConditionPB.Descending,
);
});
test('reorder sorts', () async {
final selectOptionField = getFirstFieldByType(FieldType.SingleSelect);
final checkboxField = getFirstFieldByType(FieldType.Checkbox);
sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id));
await gridResponseFuture();
sortBloc.add(SortEditorEvent.createSort(fieldId: checkboxField.id));
await gridResponseFuture();
expect(sortBloc.state.sorts[0].fieldId, selectOptionField.id);
expect(sortBloc.state.sorts[1].fieldId, checkboxField.id);
expect(sortBloc.state.creatableFields.length, equals(1));
expect(sortBloc.state.allFields.length, equals(3));
sortBloc.add(
const SortEditorEvent.reorderSort(0, 2),
);
await gridResponseFuture();
expect(sortBloc.state.sorts[0].fieldId, checkboxField.id);
expect(sortBloc.state.sorts[1].fieldId, selectOptionField.id);
expect(sortBloc.state.creatableFields.length, equals(1));
expect(sortBloc.state.allFields.length, equals(3));
});
test('delete sort', () async {
final selectOptionField = getFirstFieldByType(FieldType.SingleSelect);
sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id));
await gridResponseFuture();
expect(sortBloc.state.sorts.length, 1);
sortBloc.add(
SortEditorEvent.deleteSort(sortBloc.state.sorts.first.sortId),
);
await gridResponseFuture();
expect(sortBloc.state.sorts.length, 0);
expect(sortBloc.state.creatableFields.length, equals(3));
expect(sortBloc.state.allFields.length, equals(3));
});
test('delete all sorts', () async {
final selectOptionField = getFirstFieldByType(FieldType.SingleSelect);
final checkboxField = getFirstFieldByType(FieldType.Checkbox);
sortBloc
..add(SortEditorEvent.createSort(fieldId: selectOptionField.id))
..add(SortEditorEvent.createSort(fieldId: checkboxField.id));
await gridResponseFuture();
expect(sortBloc.state.sorts.length, 2);
sortBloc.add(const SortEditorEvent.deleteAllSorts());
await gridResponseFuture();
expect(sortBloc.state.sorts.length, 0);
expect(sortBloc.state.creatableFields.length, equals(3));
expect(sortBloc.state.allFields.length, equals(3));
});
test('update sort field', () async {
final selectOptionField = getFirstFieldByType(FieldType.SingleSelect);
sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id));
await gridResponseFuture();
expect(sortBloc.state.sorts.length, equals(1));
// edit field
await FieldBackendService(
viewId: context.view.id,
fieldId: selectOptionField.id,
).updateField(name: "HERRO");
await gridResponseFuture();
expect(sortBloc.state.sorts.length, equals(1));
expect(sortBloc.state.allFields[1].name, "HERRO");
expect(sortBloc.state.creatableFields.length, equals(2));
expect(sortBloc.state.allFields.length, equals(3));
});
test('delete sorting field', () async {
final selectOptionField = getFirstFieldByType(FieldType.SingleSelect);
sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id));
await gridResponseFuture();
expect(sortBloc.state.sorts.length, equals(1));
// edit field
await FieldBackendService(
viewId: context.view.id,
fieldId: selectOptionField.id,
).delete();
await gridResponseFuture();
expect(sortBloc.state.sorts.length, equals(0));
expect(sortBloc.state.creatableFields.length, equals(2));
expect(sortBloc.state.allFields.length, equals(2));
});
});
}
@@ -1,91 +1,52 @@
import 'dart:convert';
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/field/field_controller.dart';
import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart';
import 'package:appflowy/plugins/database/application/field/field_info.dart';
import 'package:appflowy/plugins/database/domain/field_service.dart';
import 'package:appflowy/plugins/database/application/row/row_cache.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/application/row/row_service.dart';
import 'package:appflowy/workspace/application/settings/share/import_service.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter/services.dart';
import '../../util.dart';
class GridTestContext {
GridTestContext(this.gridView, this.gridController);
const v020GridFileName = "v020.afdb";
const v069GridFileName = "v069.afdb";
final ViewPB gridView;
final DatabaseController gridController;
class GridTestContext {
GridTestContext(this.view, this.databaseController);
final ViewPB view;
final DatabaseController databaseController;
List<RowInfo> get rowInfos {
return gridController.rowCache.rowInfos;
return databaseController.rowCache.rowInfos;
}
List<FieldInfo> get fieldInfos => fieldController.fieldInfos;
FieldController get fieldController {
return gridController.fieldController;
}
FieldController get fieldController => databaseController.fieldController;
Future<FieldEditorBloc> createField(FieldType fieldType) async {
final editorBloc =
await createFieldEditor(databaseController: gridController);
await createFieldEditor(databaseController: databaseController);
await gridResponseFuture();
editorBloc.add(FieldEditorEvent.switchFieldType(fieldType));
await gridResponseFuture();
return Future(() => editorBloc);
return editorBloc;
}
FieldInfo getSelectOptionField() {
final fieldInfo = fieldInfos
.firstWhere((element) => element.fieldType == FieldType.SingleSelect);
return fieldInfo;
}
FieldInfo getTextField() {
final fieldInfo = fieldInfos
.firstWhere((element) => element.fieldType == FieldType.RichText);
return fieldInfo;
}
FieldInfo getCheckboxField() {
final fieldInfo = fieldInfos
.firstWhere((element) => element.fieldType == FieldType.Checkbox);
return fieldInfo;
}
SelectOptionCellController makeSelectOptionCellController(
FieldType fieldType,
int rowIndex,
) {
assert(
fieldType == FieldType.SingleSelect || fieldType == FieldType.MultiSelect,
);
final field =
fieldInfos.firstWhere((fieldInfo) => fieldInfo.fieldType == fieldType);
CellController makeGridCellController(int fieldIndex, int rowIndex) {
return makeCellController(
gridController,
CellContext(fieldId: field.id, rowId: rowInfos[rowIndex].rowId),
).as();
}
TextCellController makeTextCellController(int rowIndex) {
final field = fieldInfos
.firstWhere((element) => element.fieldType == FieldType.RichText);
return makeCellController(
gridController,
CellContext(fieldId: field.id, rowId: rowInfos[rowIndex].rowId),
).as();
}
CheckboxCellController makeCheckboxCellController(int rowIndex) {
final field = fieldInfos
.firstWhere((element) => element.fieldType == FieldType.Checkbox);
return makeCellController(
gridController,
CellContext(fieldId: field.id, rowId: rowInfos[rowIndex].rowId),
databaseController,
CellContext(
fieldId: fieldController.fieldInfos[fieldIndex].id,
rowId: rowInfos[rowIndex].rowId,
),
).as();
}
}
@@ -121,65 +82,74 @@ class AppFlowyGridTest {
return AppFlowyGridTest(unitTest: inner);
}
Future<GridTestContext> createTestGrid() async {
final app = await unitTest.createWorkspace();
Future<GridTestContext> makeDefaultTestGrid() async {
final workspace = await unitTest.createWorkspace();
final context = await ViewBackendService.createView(
parentViewId: app.id,
parentViewId: workspace.id,
name: "Test Grid",
layoutType: ViewLayoutPB.Grid,
openAfterCreate: true,
).then((result) {
return result.fold(
(view) async {
final context = GridTestContext(
view,
DatabaseController(view: view),
);
final result = await context.gridController.open();
result.fold((l) => null, (r) => throw Exception(r));
return context;
},
(error) {
throw Exception();
},
);
});
).fold(
(view) async {
final databaseController = DatabaseController(view: view);
await databaseController
.open()
.fold((l) => null, (r) => throw Exception(r));
return GridTestContext(
view,
databaseController,
);
},
(error) => throw Exception(),
);
return context;
}
Future<GridTestContext> makeTestGridFromImportedData(
String fileName,
) async {
final workspace = await unitTest.createWorkspace();
// Don't use the p.join to build the path that used in loadString. It
// is not working on windows.
final data = await rootBundle
.loadString("assets/test/workspaces/database/$fileName");
final context = await ImportBackendService.importPages(
workspace.id,
[
ImportValuePayloadPB()
..name = fileName
..data = utf8.encode(data)
..viewLayout = ViewLayoutPB.Grid
..importType = ImportTypePB.RawDatabase,
],
).fold(
(views) async {
final view = views.items.first;
final databaseController = DatabaseController(view: view);
await databaseController
.open()
.fold((l) => null, (r) => throw Exception(r));
return GridTestContext(
view,
databaseController,
);
},
(err) => throw Exception(),
);
return context;
}
}
/// Create a new Grid for cell test
class AppFlowyGridCellTest {
AppFlowyGridCellTest({required this.gridTest});
late GridTestContext context;
final AppFlowyGridTest gridTest;
static Future<AppFlowyGridCellTest> ensureInitialized() async {
final gridTest = await AppFlowyGridTest.ensureInitialized();
return AppFlowyGridCellTest(gridTest: gridTest);
}
Future<void> createTestGrid() async {
context = await gridTest.createTestGrid();
}
Future<void> createTestRow() async {
await RowBackendService.createRow(viewId: context.gridView.id);
}
SelectOptionCellController makeSelectOptionCellController(
FieldType fieldType,
int rowIndex,
) =>
context.makeSelectOptionCellController(fieldType, rowIndex);
Future<void> gridResponseFuture({int milliseconds = 300}) {
return Future.delayed(
gridResponseDuration(milliseconds: milliseconds),
);
}
Future<void> gridResponseFuture({int milliseconds = 400}) {
return Future.delayed(gridResponseDuration(milliseconds: milliseconds));
}
Duration gridResponseDuration({int milliseconds = 400}) {
Duration gridResponseDuration({int milliseconds = 300}) {
return Duration(milliseconds: milliseconds);
}
@@ -9,7 +9,7 @@ import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockAIRepository extends Mock implements AIRepository {
class _MockAIRepository extends Mock implements AIRepository {
@override
Future<void> streamCompletion({
required String text,
@@ -28,6 +28,26 @@ class MockAIRepository extends Mock implements AIRepository {
}
}
class _MockErrorRepository extends Mock implements AIRepository {
@override
Future<void> streamCompletion({
required String text,
required CompletionTypePB completionType,
required Future<void> Function() onStart,
required Future<void> Function(String text) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
}) async {
await onStart();
onError(
const AIError(
message: 'Error',
code: AIErrorCode.aiResponseLimitExceeded,
),
);
}
}
void main() {
group('SmartEditorBloc: ', () {
blocTest<SmartEditBloc, SmartEditState>(
@@ -64,7 +84,7 @@ void main() {
);
},
act: (bloc) {
bloc.add(SmartEditEvent.initial(Future.value(MockAIRepository())));
bloc.add(SmartEditEvent.initial(Future.value(_MockAIRepository())));
bloc.add(const SmartEditEvent.rewrite());
},
expect: () => [
@@ -78,17 +98,56 @@ void main() {
isA<SmartEditState>().having((s) => s.loading, 'loading', false),
],
);
blocTest<SmartEditBloc, SmartEditState>(
'exceed the ai response limit',
build: () {
const text1 = '1. Select text to style using the toolbar menu.';
const text2 = '2. Discover more styling options in Aa.';
const text3 =
'3. AppFlowy empowers you to beautifully and effortlessly style your content.';
final document = Document(
root: pageNode(
children: [
paragraphNode(text: text1),
paragraphNode(text: text2),
paragraphNode(text: text3),
],
),
);
final editorState = EditorState(document: document);
editorState.selection = Selection(
start: Position(path: [0]),
end: Position(path: [2], offset: text3.length),
);
final node = smartEditNode(
action: SmartEditAction.makeItLonger,
content: [text1, text2, text3].join('\n'),
);
return SmartEditBloc(
node: node,
editorState: editorState,
action: SmartEditAction.makeItLonger,
enableLogging: false,
);
},
act: (bloc) {
bloc.add(SmartEditEvent.initial(Future.value(_MockErrorRepository())));
bloc.add(const SmartEditEvent.rewrite());
},
expect: () => [
isA<SmartEditState>()
.having((s) => s.loading, 'loading', true)
.having((s) => s.result, 'result', isEmpty),
isA<SmartEditState>()
.having((s) => s.requestError, 'requestError', isNotNull)
.having(
(s) => s.requestError?.code,
'requestError.code',
AIErrorCode.aiResponseLimitExceeded,
),
],
);
});
}
// [
// _$SmartEditStateImpl:SmartEditState(loading: true, result: , action: SmartEditAction.makeItLonger),
// _$SmartEditStateImpl:SmartEditState(loading: false, result: UPDATED: 1. Select text to style using the toolbar menu.
// 2. Discover more styling options in Aa.
// 3. AppFlowy empowers you to beautifully and effortlessly style your content.
// , action: SmartEditAction.makeItLonger),
// _$SmartEditStateImpl:SmartEditState(loading: false, result:
// , action: SmartEditAction.makeItLonger)
// ]
@@ -1,10 +1,13 @@
import 'dart:async';
import 'package:appflowy/plugins/document/application/document_service.dart';
import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart';
import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('TransactionAdapter', () {
group('TransactionAdapter:', () {
test('toBlockAction insert node with children operation', () {
final editorState = EditorState.blank();
@@ -148,5 +151,76 @@ void main() {
reason: '1 - prev id',
);
});
test('update the external id and external type', () async {
// create a node without external id and external type
// the editing this node, the adapter should generate a new action
// to assign a new external id and external type.
final node = bulletedListNode(text: 'Hello');
final document = Document(
root: pageNode(
children: [
node,
],
),
);
final transactionAdapter = TransactionAdapter(
documentId: '',
documentService: DocumentService(),
);
final editorState = EditorState(
document: document,
);
final completer = Completer();
editorState.transactionStream.listen((event) {
final time = event.$1;
if (time == TransactionTime.before) {
final actions = transactionAdapter.transactionToBlockActions(
event.$2,
editorState,
);
final textActions =
transactionAdapter.filterTextDeltaActions(actions);
final blockActions = transactionAdapter.filterBlockActions(actions);
expect(textActions.length, 1);
expect(blockActions.length, 1);
// check text operation
final textAction = textActions.first;
final textId = textAction.textDeltaPayloadPB?.textId;
{
expect(textAction.textDeltaType, TextDeltaType.create);
expect(textId, isNotEmpty);
final delta = textAction.textDeltaPayloadPB?.delta;
expect(delta, equals('[{"insert":"HelloWorld"}]'));
}
// check block operation
{
final blockAction = blockActions.first;
expect(blockAction.action, BlockActionTypePB.Update);
expect(blockAction.payload.block.id, node.id);
expect(
blockAction.payload.block.externalId,
textId,
);
expect(blockAction.payload.block.externalType, 'text');
}
} else if (time == TransactionTime.after) {
completer.complete();
}
});
await editorState.insertText(
5,
'World',
node: node,
);
await completer.future;
});
});
}
+2 -2
View File
@@ -24,7 +24,7 @@ class AppFlowyUnitTest {
_pathProviderInitialized();
await FlowyRunner.run(
AppFlowyApplicationUniTest(),
AppFlowyApplicationUnitTest(),
IntegrationMode.unitTest,
);
@@ -93,7 +93,7 @@ void _pathProviderInitialized() {
});
}
class AppFlowyApplicationUniTest implements EntryPoint {
class AppFlowyApplicationUnitTest implements EntryPoint {
@override
Widget create(LaunchConfiguration config) {
return const SizedBox.shrink();
+22 -20
View File
@@ -172,7 +172,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
[[package]]
name = "app-error"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=98d9f823b2e08dedf093815364cf73e8b8903e77#98d9f823b2e08dedf093815364cf73e8b8903e77"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9c0dffacef6b58070002d988fffd92d09e5516a8#9c0dffacef6b58070002d988fffd92d09e5516a8"
dependencies = [
"anyhow",
"bincode",
@@ -192,7 +192,7 @@ dependencies = [
[[package]]
name = "appflowy-ai-client"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=98d9f823b2e08dedf093815364cf73e8b8903e77#98d9f823b2e08dedf093815364cf73e8b8903e77"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9c0dffacef6b58070002d988fffd92d09e5516a8#9c0dffacef6b58070002d988fffd92d09e5516a8"
dependencies = [
"anyhow",
"bytes",
@@ -848,7 +848,7 @@ dependencies = [
[[package]]
name = "client-api"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=98d9f823b2e08dedf093815364cf73e8b8903e77#98d9f823b2e08dedf093815364cf73e8b8903e77"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9c0dffacef6b58070002d988fffd92d09e5516a8#9c0dffacef6b58070002d988fffd92d09e5516a8"
dependencies = [
"again",
"anyhow",
@@ -901,7 +901,7 @@ dependencies = [
[[package]]
name = "client-api-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=98d9f823b2e08dedf093815364cf73e8b8903e77#98d9f823b2e08dedf093815364cf73e8b8903e77"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9c0dffacef6b58070002d988fffd92d09e5516a8#9c0dffacef6b58070002d988fffd92d09e5516a8"
dependencies = [
"collab-entity",
"collab-rt-entity",
@@ -914,7 +914,7 @@ dependencies = [
[[package]]
name = "client-websocket"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=98d9f823b2e08dedf093815364cf73e8b8903e77#98d9f823b2e08dedf093815364cf73e8b8903e77"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9c0dffacef6b58070002d988fffd92d09e5516a8#9c0dffacef6b58070002d988fffd92d09e5516a8"
dependencies = [
"futures-channel",
"futures-util",
@@ -988,7 +988,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=38449cb51673fa0c1c6565d769cf417d2cb8db5a#38449cb51673fa0c1c6565d769cf417d2cb8db5a"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b56d051#b56d0512acf2d3a3857c8e1d425e04ed38763516"
dependencies = [
"anyhow",
"arc-swap",
@@ -1013,7 +1013,7 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=38449cb51673fa0c1c6565d769cf417d2cb8db5a#38449cb51673fa0c1c6565d769cf417d2cb8db5a"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b56d051#b56d0512acf2d3a3857c8e1d425e04ed38763516"
dependencies = [
"anyhow",
"async-trait",
@@ -1052,7 +1052,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=38449cb51673fa0c1c6565d769cf417d2cb8db5a#38449cb51673fa0c1c6565d769cf417d2cb8db5a"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b56d051#b56d0512acf2d3a3857c8e1d425e04ed38763516"
dependencies = [
"anyhow",
"arc-swap",
@@ -1073,7 +1073,7 @@ dependencies = [
[[package]]
name = "collab-entity"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=38449cb51673fa0c1c6565d769cf417d2cb8db5a#38449cb51673fa0c1c6565d769cf417d2cb8db5a"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b56d051#b56d0512acf2d3a3857c8e1d425e04ed38763516"
dependencies = [
"anyhow",
"bytes",
@@ -1093,7 +1093,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=38449cb51673fa0c1c6565d769cf417d2cb8db5a#38449cb51673fa0c1c6565d769cf417d2cb8db5a"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b56d051#b56d0512acf2d3a3857c8e1d425e04ed38763516"
dependencies = [
"anyhow",
"arc-swap",
@@ -1115,7 +1115,7 @@ dependencies = [
[[package]]
name = "collab-importer"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=38449cb51673fa0c1c6565d769cf417d2cb8db5a#38449cb51673fa0c1c6565d769cf417d2cb8db5a"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b56d051#b56d0512acf2d3a3857c8e1d425e04ed38763516"
dependencies = [
"anyhow",
"async-recursion",
@@ -1170,7 +1170,7 @@ dependencies = [
[[package]]
name = "collab-plugins"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=38449cb51673fa0c1c6565d769cf417d2cb8db5a#38449cb51673fa0c1c6565d769cf417d2cb8db5a"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b56d051#b56d0512acf2d3a3857c8e1d425e04ed38763516"
dependencies = [
"anyhow",
"async-stream",
@@ -1208,7 +1208,7 @@ dependencies = [
[[package]]
name = "collab-rt-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=98d9f823b2e08dedf093815364cf73e8b8903e77#98d9f823b2e08dedf093815364cf73e8b8903e77"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9c0dffacef6b58070002d988fffd92d09e5516a8#9c0dffacef6b58070002d988fffd92d09e5516a8"
dependencies = [
"anyhow",
"bincode",
@@ -1233,7 +1233,7 @@ dependencies = [
[[package]]
name = "collab-rt-protocol"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=98d9f823b2e08dedf093815364cf73e8b8903e77#98d9f823b2e08dedf093815364cf73e8b8903e77"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9c0dffacef6b58070002d988fffd92d09e5516a8#9c0dffacef6b58070002d988fffd92d09e5516a8"
dependencies = [
"anyhow",
"async-trait",
@@ -1250,7 +1250,7 @@ dependencies = [
[[package]]
name = "collab-user"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=38449cb51673fa0c1c6565d769cf417d2cb8db5a#38449cb51673fa0c1c6565d769cf417d2cb8db5a"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b56d051#b56d0512acf2d3a3857c8e1d425e04ed38763516"
dependencies = [
"anyhow",
"collab",
@@ -1624,7 +1624,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308"
[[package]]
name = "database-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=98d9f823b2e08dedf093815364cf73e8b8903e77#98d9f823b2e08dedf093815364cf73e8b8903e77"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9c0dffacef6b58070002d988fffd92d09e5516a8#9c0dffacef6b58070002d988fffd92d09e5516a8"
dependencies = [
"anyhow",
"app-error",
@@ -2190,6 +2190,7 @@ dependencies = [
"client-api",
"collab",
"collab-entity",
"collab-folder",
"collab-integrate",
"collab-plugins",
"dashmap 6.0.1",
@@ -2692,6 +2693,7 @@ dependencies = [
"lib-infra",
"once_cell",
"protobuf",
"rayon",
"semver",
"serde",
"serde_json",
@@ -3192,7 +3194,7 @@ dependencies = [
[[package]]
name = "gotrue"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=98d9f823b2e08dedf093815364cf73e8b8903e77#98d9f823b2e08dedf093815364cf73e8b8903e77"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9c0dffacef6b58070002d988fffd92d09e5516a8#9c0dffacef6b58070002d988fffd92d09e5516a8"
dependencies = [
"anyhow",
"futures-util",
@@ -3209,7 +3211,7 @@ dependencies = [
[[package]]
name = "gotrue-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=98d9f823b2e08dedf093815364cf73e8b8903e77#98d9f823b2e08dedf093815364cf73e8b8903e77"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9c0dffacef6b58070002d988fffd92d09e5516a8#9c0dffacef6b58070002d988fffd92d09e5516a8"
dependencies = [
"anyhow",
"app-error",
@@ -3641,7 +3643,7 @@ dependencies = [
[[package]]
name = "infra"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=98d9f823b2e08dedf093815364cf73e8b8903e77#98d9f823b2e08dedf093815364cf73e8b8903e77"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9c0dffacef6b58070002d988fffd92d09e5516a8#9c0dffacef6b58070002d988fffd92d09e5516a8"
dependencies = [
"anyhow",
"bytes",
@@ -6279,7 +6281,7 @@ dependencies = [
[[package]]
name = "shared-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=98d9f823b2e08dedf093815364cf73e8b8903e77#98d9f823b2e08dedf093815364cf73e8b8903e77"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9c0dffacef6b58070002d988fffd92d09e5516a8#9c0dffacef6b58070002d988fffd92d09e5516a8"
dependencies = [
"anyhow",
"app-error",
+8 -20
View File
@@ -120,26 +120,14 @@ custom-protocol = ["tauri/custom-protocol"]
# To switch to the local path, run:
# scripts/tool/update_collab_source.sh
# ⚠️⚠️⚠️️
<<<<<<< Updated upstream
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
= = = = = = =
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
>>>>>>> Stashed changes
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b56d051" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b56d051" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b56d051" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b56d051" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b56d051" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b56d051" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b56d051" }
collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b56d051" }
# Working directory: frontend
# To update the commit ID, run:
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

@@ -13,6 +13,7 @@
// https://on.cypress.io/configuration
// ***********************************************************
import { addMatchImageSnapshotCommand } from 'cypress-image-snapshot/command';
import 'cypress-real-events';
// Import commands.js using ES2015 syntax:
import '@cypress/code-coverage/support';
+2
View File
@@ -46,6 +46,7 @@
"dexie-react-hooks": "^1.1.7",
"emoji-mart": "^5.5.2",
"emoji-regex": "^10.2.1",
"escape-string-regexp": "^5.0.0",
"events": "^3.3.0",
"google-protobuf": "^3.15.12",
"highlight.js": "^11.10.0",
@@ -150,6 +151,7 @@
"cross-env": "^7.0.3",
"cypress": "^13.7.2",
"cypress-image-snapshot": "^4.0.1",
"cypress-real-events": "^1.13.0",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
+19
View File
@@ -71,6 +71,9 @@ dependencies:
emoji-regex:
specifier: ^10.2.1
version: 10.3.0
escape-string-regexp:
specifier: ^5.0.0
version: 5.0.0
events:
specifier: ^3.3.0
version: 3.3.0
@@ -379,6 +382,9 @@ devDependencies:
cypress-image-snapshot:
specifier: ^4.0.1
version: 4.0.1(cypress@13.7.2)(jest@29.5.0)
cypress-real-events:
specifier: ^1.13.0
version: 1.13.0(cypress@13.7.2)
eslint:
specifier: ^8.57.0
version: 8.57.0
@@ -5801,6 +5807,14 @@ packages:
- jest
dev: true
/cypress-real-events@1.13.0(cypress@13.7.2):
resolution: {integrity: sha512-LoejtK+dyZ1jaT8wGT5oASTPfsNV8/ClRp99ruN60oPj8cBJYod80iJDyNwfPAu4GCxTXOhhAv9FO65Hpwt6Hg==}
peerDependencies:
cypress: ^4.x || ^5.x || ^6.x || ^7.x || ^8.x || ^9.x || ^10.x || ^11.x || ^12.x || ^13.x
dependencies:
cypress: 13.7.2
dev: true
/cypress@13.7.2:
resolution: {integrity: sha512-FF5hFI5wlRIHY8urLZjJjj/YvfCBrRpglbZCLr/cYcL9MdDe0+5usa8kTIrDHthlEc9lwihbkb5dmwqBDNS2yw==}
engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0}
@@ -6378,6 +6392,11 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
/escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
dev: false
/escodegen@2.1.0:
resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==}
engines: {node: '>=6.0'}
+8 -8
View File
@@ -986,7 +986,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d73579b0269b33856690c4b4d68bf22b4eb17a99#d73579b0269b33856690c4b4d68bf22b4eb17a99"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b56d051#b56d0512acf2d3a3857c8e1d425e04ed38763516"
dependencies = [
"anyhow",
"arc-swap",
@@ -1011,7 +1011,7 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d73579b0269b33856690c4b4d68bf22b4eb17a99#d73579b0269b33856690c4b4d68bf22b4eb17a99"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b56d051#b56d0512acf2d3a3857c8e1d425e04ed38763516"
dependencies = [
"anyhow",
"async-trait",
@@ -1050,7 +1050,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d73579b0269b33856690c4b4d68bf22b4eb17a99#d73579b0269b33856690c4b4d68bf22b4eb17a99"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b56d051#b56d0512acf2d3a3857c8e1d425e04ed38763516"
dependencies = [
"anyhow",
"arc-swap",
@@ -1071,7 +1071,7 @@ dependencies = [
[[package]]
name = "collab-entity"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d73579b0269b33856690c4b4d68bf22b4eb17a99#d73579b0269b33856690c4b4d68bf22b4eb17a99"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b56d051#b56d0512acf2d3a3857c8e1d425e04ed38763516"
dependencies = [
"anyhow",
"bytes",
@@ -1091,7 +1091,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d73579b0269b33856690c4b4d68bf22b4eb17a99#d73579b0269b33856690c4b4d68bf22b4eb17a99"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b56d051#b56d0512acf2d3a3857c8e1d425e04ed38763516"
dependencies = [
"anyhow",
"arc-swap",
@@ -1113,7 +1113,7 @@ dependencies = [
[[package]]
name = "collab-importer"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d73579b0269b33856690c4b4d68bf22b4eb17a99#d73579b0269b33856690c4b4d68bf22b4eb17a99"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b56d051#b56d0512acf2d3a3857c8e1d425e04ed38763516"
dependencies = [
"anyhow",
"async-recursion",
@@ -1168,7 +1168,7 @@ dependencies = [
[[package]]
name = "collab-plugins"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d73579b0269b33856690c4b4d68bf22b4eb17a99#d73579b0269b33856690c4b4d68bf22b4eb17a99"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b56d051#b56d0512acf2d3a3857c8e1d425e04ed38763516"
dependencies = [
"anyhow",
"async-stream",
@@ -1248,7 +1248,7 @@ dependencies = [
[[package]]
name = "collab-user"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d73579b0269b33856690c4b4d68bf22b4eb17a99#d73579b0269b33856690c4b4d68bf22b4eb17a99"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b56d051#b56d0512acf2d3a3857c8e1d425e04ed38763516"
dependencies = [
"anyhow",
"collab",
@@ -118,14 +118,14 @@ custom-protocol = ["tauri/custom-protocol"]
# To switch to the local path, run:
# scripts/tool/update_collab_source.sh
# ⚠️⚠️⚠️️
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d73579b0269b33856690c4b4d68bf22b4eb17a99" }
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b56d051" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b56d051" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b56d051" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b56d051" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b56d051" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b56d051" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b56d051" }
collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b56d051" }
# Working directory: frontend
@@ -20,6 +20,7 @@ export function generateId () {
export function withTestingYjsEditor (editor: Editor, doc: Y.Doc) {
const yjdEditor = withYjs(editor, doc, {
localOrigin: CollabOrigin.Local,
readOnly: true,
});
return yjdEditor;
@@ -0,0 +1,16 @@
import { BlockType } from '@/application/types';
/** List block types */
export const ListBlockTypes = [BlockType.TodoListBlock, BlockType.BulletedListBlock, BlockType.NumberedListBlock];
/** Container block types */
export const CONTAINER_BLOCK_TYPES = [
BlockType.ToggleListBlock,
BlockType.TodoListBlock,
BlockType.Paragraph,
BlockType.QuoteBlock,
BlockType.BulletedListBlock,
BlockType.NumberedListBlock,
BlockType.Page,
];
export const SOFT_BREAK_TYPES = [BlockType.CalloutBlock, BlockType.CodeBlock];
@@ -1,17 +1,35 @@
import { YjsEditor } from '@/application/slate-yjs/plugins/withYjs';
import { EditorMarkFormat } from '@/application/slate-yjs/types';
import {
dataStringTOJson,
executeOperations,
getBlock, getBlockEntry, getSelectionOrThrow, getSharedRoot,
handleCollapsedBreak, handleDeleteEntireDocument, handleMergeBlockBackward, handleNonParagraphBlockBackspace,
handleRangeBreak, handleShouldLiftBlockBackspace,
handleMergeBlockForward,
removeRange,
getBlock,
getBlockEntry,
getSelectionOrThrow,
getSharedRoot,
handleCollapsedBreakWithTxn,
handleDeleteEntireDocumentWithTxn,
handleIndentBlockWithTxn,
handleLiftBlockOnBackspaceAndEnterWithTxn,
handleLiftBlockOnTabWithTxn,
handleMergeBlockBackwardWithTxn,
handleMergeBlockForwardWithTxn,
handleNonParagraphBlockBackspaceAndEnterWithTxn,
handleRangeBreak,
removeRangeWithTxn,
turnToBlock,
} from '@/application/slate-yjs/utils/yjsOperations';
import { BlockData, BlockType, InlineBlockType, Mention, MentionType, YjsEditorKey } from '@/application/types';
import { FormulaNode } from '@/components/editor/editor.type';
import {
BlockData,
BlockType,
MentionType,
TodoListBlockData,
ToggleListBlockData,
YjsEditorKey,
} from '@/application/types';
import { renderDate } from '@/utils/time';
import { BaseRange, Editor, Element, Node, NodeEntry, Range, Text, Transforms } from 'slate';
import isEqual from 'lodash-es/isEqual';
import { BasePoint, BaseRange, Editor, Element, Node, NodeEntry, Path, Range, Text, Transforms } from 'slate';
import { ReactEditor } from 'slate-react';
export const CustomEditor = {
@@ -25,19 +43,6 @@ export const CustomEditor = {
},
// Get the text content of a block node, including the text content of its children and formula nodes
getBlockTextContent (node: Node): string {
if (Element.isElement(node)) {
if (node.type === InlineBlockType.Formula) {
return (node as FormulaNode).data || '';
}
if (node.type === InlineBlockType.Mention && (node.data as Mention)?.type === MentionType.Date) {
const date = (node.data as Mention).date || '';
const isUnix = date?.length === 10;
return renderDate(date, 'MMM DD, YYYY', isUnix);
}
}
if (Text.isText(node)) {
if (node.formula) {
return node.formula;
@@ -63,9 +68,8 @@ export const CustomEditor = {
},
setBlockData<T = BlockData> (editor: YjsEditor, blockId: string, updateData: T, select?: boolean) {
const readonly = editor.isElementReadOnly(editor);
if (readonly) {
if (editor.readOnly) {
return;
}
@@ -75,25 +79,36 @@ export const CustomEditor = {
...oldData,
...updateData,
};
const operations = [() => {
block.set(YjsEditorKey.block_data, JSON.stringify(newData));
}];
const entry = Editor.above(editor, {
const newProperties = {
data: newData,
} as Partial<Element>;
const [entry] = editor.nodes({
at: [],
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId === blockId,
});
executeOperations(editor.sharedRoot, operations, 'setBlockData');
if (!select) return;
if (!entry) {
Transforms.select(editor, Editor.start(editor, [0]));
console.error('Block not found');
return;
}
const nodePath = entry[1];
const [, path] = entry;
let atChild = false;
const { selection } = editor;
if (selection && Path.isAncestor(path, selection.anchor.path)) {
atChild = true;
}
Transforms.setNodes(editor, newProperties, { at: path });
if (!select) return;
if (atChild) {
Transforms.select(editor, Editor.start(editor, path));
}
Transforms.select(editor, Editor.start(editor, nodePath));
},
// Insert break line at the specified path
insertBreak (editor: YjsEditor, at?: BaseRange) {
@@ -103,7 +118,7 @@ export const CustomEditor = {
const isCollapsed = Range.isCollapsed(newAt);
if (isCollapsed) {
handleCollapsedBreak(editor, sharedRoot, newAt);
handleCollapsedBreakWithTxn(editor, sharedRoot, newAt);
} else {
handleRangeBreak(editor, sharedRoot, newAt);
}
@@ -126,18 +141,18 @@ export const CustomEditor = {
const blockType = block.get(YjsEditorKey.block_type) as BlockType;
if (blockType !== BlockType.Paragraph) {
handleNonParagraphBlockBackspace(sharedRoot, block);
handleNonParagraphBlockBackspaceAndEnterWithTxn(sharedRoot, block);
return;
}
if (path.length > 1 && handleShouldLiftBlockBackspace(editor, sharedRoot, block, point)) {
if (path.length > 1 && handleLiftBlockOnBackspaceAndEnterWithTxn(editor, sharedRoot, block, point)) {
return;
}
handleMergeBlockBackward(editor, node, point);
handleMergeBlockBackwardWithTxn(editor, node, point);
} else {
Transforms.collapse(editor, { edge: 'start' });
removeRange(editor, sharedRoot, newAt);
removeRangeWithTxn(editor, sharedRoot, newAt);
}
},
@@ -154,18 +169,103 @@ export const CustomEditor = {
const [node] = blockEntry as NodeEntry<Element>;
handleMergeBlockForward(editor, node, point);
handleMergeBlockForwardWithTxn(editor, node, point);
} else {
Transforms.collapse(editor, { edge: 'start' });
removeRange(editor, sharedRoot, newAt);
removeRangeWithTxn(editor, sharedRoot, newAt);
}
},
deleteEntireDocument (editor: YjsEditor) {
handleDeleteEntireDocument(editor);
handleDeleteEntireDocumentWithTxn(editor);
},
removeRange (editor: YjsEditor, at: BaseRange) {
removeRange(editor, getSharedRoot(editor), at);
removeRangeWithTxn(editor, getSharedRoot(editor), at);
},
tabForward (editor: YjsEditor, point: BasePoint) {
const sharedRoot = getSharedRoot(editor);
const [node] = getBlockEntry(editor, point);
const block = getBlock(node.blockId as string, sharedRoot);
handleIndentBlockWithTxn(editor, sharedRoot, block, point);
},
tabBackward (editor: YjsEditor, point: BasePoint) {
const sharedRoot = getSharedRoot(editor);
const [node] = getBlockEntry(editor, point);
const block = getBlock(node.blockId as string, sharedRoot);
handleLiftBlockOnTabWithTxn(editor, sharedRoot, block, point);
},
toggleToggleList (editor: YjsEditor, blockId: string) {
const sharedRoot = getSharedRoot(editor);
const data = dataStringTOJson(getBlock(blockId, sharedRoot).get(YjsEditorKey.block_data)) as ToggleListBlockData;
const { selection } = editor;
if (!selection) return;
if (Range.isExpanded(selection)) {
Transforms.collapse(editor, { edge: 'start' });
}
const point = Editor.start(editor, selection);
const [node] = getBlockEntry(editor, point);
CustomEditor.setBlockData(editor, blockId, {
collapsed: !data.collapsed,
}, node.blockId !== blockId);
},
toggleTodoList (editor: YjsEditor, blockId: string) {
const sharedRoot = getSharedRoot(editor);
const data = dataStringTOJson(getBlock(blockId, sharedRoot).get(YjsEditorKey.block_data)) as TodoListBlockData;
CustomEditor.setBlockData(editor, blockId, {
checked: !data.checked,
}, false);
},
toggleMark (editor: ReactEditor, {
key, value,
}: {
key: EditorMarkFormat, value: boolean | string
}) {
if (editor.marks && Object.keys(editor.marks).includes(key)) {
editor.removeMark(key);
} else {
editor.addMark(key, value);
}
},
addMark (editor: ReactEditor, {
key, value,
}: {
key: EditorMarkFormat, value: boolean | string
}) {
editor.addMark(key, value);
},
turnToBlock<T extends BlockData> (editor: YjsEditor, blockId: string, type: BlockType, data: T) {
const operations: (() => void)[] = [];
const sharedRoot = getSharedRoot(editor);
const sourceBlock = getBlock(blockId, sharedRoot);
const sourceType = sourceBlock.get(YjsEditorKey.block_type) as BlockType;
const oldData = dataStringTOJson(sourceBlock.get(YjsEditorKey.block_data));
if (sourceType === type && isEqual(oldData, data)) {
return;
}
operations.push(() => {
turnToBlock(sharedRoot, sourceBlock, type, data);
});
executeOperations(sharedRoot, operations, 'turnToBlock');
},
};
@@ -39,7 +39,7 @@ export function withYHistory<T extends YjsEditor> (
): T & YHistoryEditor {
const e = editor as T & YHistoryEditor;
if (Editor.isElementReadOnly(e, e)) {
if (e.readOnly) {
return e;
}
@@ -52,6 +52,7 @@ export function withYHistory<T extends YjsEditor> (
e.onChange = () => {
onChange();
const selection = e.selection;
try {
@@ -11,6 +11,7 @@ type LocalChange = {
};
export interface YjsEditor extends Editor {
readOnly: boolean;
isYjsEditor: (value: unknown) => value is YjsEditor;
connect: () => void;
disconnect: () => void;
@@ -71,16 +72,18 @@ export function withYjs<T extends Editor> (
editor: T,
doc: Y.Doc,
opts?: {
readOnly: boolean;
localOrigin: CollabOrigin;
readSummary?: boolean;
onContentChange?: (content: Descendant[]) => void;
},
): T & YjsEditor {
const { localOrigin = CollabOrigin.Local, readSummary, onContentChange } = opts ?? {};
const { localOrigin = CollabOrigin.Local, readSummary, onContentChange, readOnly = true } = opts ?? {};
const e = editor as T & YjsEditor;
const { apply, onChange } = e;
e.interceptLocalChange = false;
e.readOnly = readOnly;
e.sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
@@ -20,3 +20,17 @@ export interface BlockJson {
external_id?: string;
}
export enum EditorMarkFormat {
Bold = 'bold',
Italic = 'italic',
Underline = 'underline',
StrikeThrough = 'strikethrough',
Code = 'code',
Href = 'href',
Formula = 'formula',
Mention = 'mention',
FontColor = 'font_color',
BgColor = 'bg_color',
Align = 'align',
}
@@ -1,47 +1,132 @@
import { CustomEditor } from '@/application/slate-yjs/command';
import { getText } from '@/application/slate-yjs/utils/yjsOperations';
import { EditorMarkFormat } from '@/application/slate-yjs/types';
import { calculateOffsetRelativeToParent } from '@/application/slate-yjs/utils/positions';
import { getBlock, getNodeAtPath, getText } from '@/application/slate-yjs/utils/yjsOperations';
import { YjsEditorKey, YSharedRoot } from '@/application/types';
import { Operation, Element, Editor, InsertTextOperation, RemoveTextOperation, Descendant } from 'slate';
import {
Descendant,
Editor,
Element, InsertNodeOperation,
InsertTextOperation,
Operation,
RemoveTextOperation,
SetNodeOperation,
Text,
} from 'slate';
import * as Y from 'yjs';
// transform slate op to yjs op and apply it to ydoc
export function applyToYjs (ydoc: Y.Doc, editor: Editor, op: Operation, slateContent: Descendant[]) {
if (op.type === 'set_selection') return;
console.log('applySlateOp', op);
console.log('applySlateOp', op, slateContent);
switch (op.type) {
case 'insert_text':
return applyInsertText(ydoc, editor, op, slateContent);
case 'remove_text':
return applyRemoveText(ydoc, editor, op, slateContent);
case 'set_node':
return applySetNode(ydoc, editor, op, slateContent);
case 'insert_node':
return applyInsertNode(ydoc, editor, op, slateContent);
default:
return;
}
}
function applyInsertText (ydoc: Y.Doc, editor: Editor, op: InsertTextOperation, _slateContent: Descendant[]) {
const { path, offset, text } = op;
function getAttributesAtOffset (ytext: Y.Text, offset: number): object | null {
const delta = ytext.toDelta();
let currentOffset = 0;
for (const op of delta) {
if ('insert' in op) {
const length = op.insert.length;
if (currentOffset <= offset && offset < currentOffset + length) {
return op.attributes || null;
}
currentOffset += length;
}
}
return null;
}
function insertText (ydoc: Y.Doc, editor: Editor, { path, offset, text, attributes }: InsertTextOperation & {
attributes?: object;
}) {
const node = findSlateNode(editor, path);
const textId = node.textId as string;
const sharedRoot = ydoc.getMap(YjsEditorKey.data_section) as YSharedRoot;
const yText = getText(textId, sharedRoot);
if (!yText) return;
const point = { path, offset };
const relativeOffset = Math.min(calculateOffsetRelativeToParent(node, point), yText.toJSON().length);
const beforeAttributes = getAttributesAtOffset(yText, relativeOffset - 1);
yText.insert(relativeOffset, text);
console.log('beforeAttributes', beforeAttributes);
if (beforeAttributes && ('formula' in beforeAttributes || 'mention' in beforeAttributes)) {
const newAttributes = {
...attributes,
};
if ('formula' in beforeAttributes) {
Object.assign({
formula: undefined,
});
}
if ('mention' in beforeAttributes) {
Object.assign({
mention: undefined,
});
}
yText.insert(relativeOffset, text, newAttributes);
} else {
yText.insert(relativeOffset, text, attributes);
}
console.log('insertText', attributes, yText.toDelta());
}
function applyInsertText (ydoc: Y.Doc, editor: Editor, op: InsertTextOperation, _slateContent: Descendant[]) {
const { path, offset, text } = op;
insertText(ydoc, editor, { path, offset, text, type: 'insert_text' });
}
function applyInsertNode (ydoc: Y.Doc, editor: Editor, op: InsertNodeOperation, _slateContent: Descendant[]) {
const { path, node } = op;
if (!Text.isText(node)) return;
const text = node.text;
const offset = 0;
insertText(ydoc, editor, {
path, offset, text, type: 'insert_text',
});
}
function applyRemoveText (ydoc: Y.Doc, editor: Editor, op: RemoveTextOperation, slateContent: Descendant[]) {
const { path, offset, text } = op;
const node = (slateContent[0] as Element)?.children?.[0] as Element;
const node = getNodeAtPath(slateContent, path.slice(0, -1)) as Element;
const textId = node.textId;
if (!textId) return;
const sharedRoot = ydoc.getMap(YjsEditorKey.data_section) as YSharedRoot;
const yText = getText(textId, sharedRoot);
if (!yText) return;
const point = { path, offset };
const relativeOffset = Math.min(calculateOffsetRelativeToParent(node, point), yText.toJSON().length);
@@ -53,4 +138,54 @@ function findSlateNode (editor: Editor, path: number[]): Element {
const entry = CustomEditor.findTextNode(editor, path);
return entry[0];
}
function applySetNode (ydoc: Y.Doc, editor: Editor, op: SetNodeOperation, slateContent: Descendant[]) {
const { newProperties, path } = op;
const leafKeys = Object.values(EditorMarkFormat) as string[];
const properties = Object.keys(newProperties);
const isLeaf = properties.some((prop: string) => leafKeys.includes(prop));
const isData = properties.some((prop: string) => prop === 'data');
const sharedRoot = ydoc.getMap(YjsEditorKey.data_section) as YSharedRoot;
console.log('applySetNode isLeaf', isLeaf, op);
if (isLeaf) {
const node = getNodeAtPath(slateContent, path.slice(0, -1)) as Element;
const textId = node.textId;
if (!textId) return;
const yText = getText(textId, sharedRoot);
const [start, end] = Editor.edges(editor, path);
const startRelativeOffset = Math.min(calculateOffsetRelativeToParent(node, start), yText.toJSON().length);
const endRelativeOffset = Math.min(calculateOffsetRelativeToParent(node, end), yText.toJSON().length);
const length = endRelativeOffset - startRelativeOffset;
yText.format(startRelativeOffset, length, newProperties);
return;
}
if (isData) {
const node = getNodeAtPath(slateContent, path) as Element;
const blockId = node.blockId as string;
if (!blockId) {
console.error('blockId is not found in node', node, newProperties);
return;
}
const block = getBlock(blockId, sharedRoot);
if (
'data' in newProperties
) {
block.set(YjsEditorKey.block_data, JSON.stringify(newProperties.data));
return;
}
}
console.error('set_node operation is not supported', op);
}
@@ -1,6 +1,8 @@
import { CONTAINER_BLOCK_TYPES, ListBlockTypes } from '@/application/slate-yjs/command/const';
import {
BlockData,
BlockType, ToggleListBlockData,
BlockType,
ToggleListBlockData,
YBlock,
YBlocks,
YChildrenMap,
@@ -13,7 +15,7 @@ import {
import { nanoid } from 'nanoid';
import Delta, { Op } from 'quill-delta';
import { BaseRange, Editor, Element, Node, NodeEntry, Path, Point, Range, Transforms } from 'slate';
import { BaseRange, Descendant, Editor, Element, Node, NodeEntry, Path, Point, Range, Transforms } from 'slate';
import * as Y from 'yjs';
import { YjsEditor } from '../plugins/withYjs';
import { slatePointToRelativePosition } from './positions';
@@ -105,9 +107,9 @@ export function updateBlockParent (sharedRoot: YSharedRoot, block: YBlock, paren
parentChildren.insert(index, [block.get(YjsEditorKey.block_id)]);
}
export function handleCollapsedBreak (editor: YjsEditor, sharedRoot: YSharedRoot, at: BaseRange) {
export function handleCollapsedBreakWithTxn (editor: YjsEditor, sharedRoot: YSharedRoot, at: BaseRange) {
const { startBlock, startOffset } = getBreakInfo(editor, sharedRoot, at);
const [blockNode] = startBlock;
const [blockNode, path] = startBlock;
const blockId = blockNode.blockId as string;
const block = getBlock(blockId, sharedRoot);
@@ -115,6 +117,22 @@ export function handleCollapsedBreak (editor: YjsEditor, sharedRoot: YSharedRoot
throw new Error('Block not found');
}
const blockType = block.get(YjsEditorKey.block_type);
const yText = getText(block.get(YjsEditorKey.block_external_id), sharedRoot);
if (yText.length === 0) {
if (blockType !== BlockType.Paragraph) {
handleNonParagraphBlockBackspaceAndEnterWithTxn(sharedRoot, block);
return;
}
const point = Editor.start(editor, at);
if (path.length > 1 && handleLiftBlockOnBackspaceAndEnterWithTxn(editor, sharedRoot, block, point)) {
return;
}
}
const { operations, select } = getSplitBlockOperations(sharedRoot, block, startOffset);
executeOperations(sharedRoot, operations, 'insertBreak');
@@ -126,7 +144,7 @@ export function handleCollapsedBreak (editor: YjsEditor, sharedRoot: YSharedRoot
}
}
export function removeRange (editor: YjsEditor, sharedRoot: YSharedRoot, range: Range) {
export function removeRangeWithTxn (editor: YjsEditor, sharedRoot: YSharedRoot, range: Range) {
const { startBlock, endBlock, middleBlocks, startRange, endRange } = getAffectedBlocks(editor, range);
const operations: (() => void)[] = [];
const isSameBlock = startBlock[0].blockId === endBlock[0].blockId;
@@ -166,13 +184,13 @@ export function liftChildren (sharedRoot: YSharedRoot, sourceBlock: YBlock, targ
}
export function handleRangeBreak (editor: YjsEditor, sharedRoot: YSharedRoot, range: Range) {
removeRange(editor, sharedRoot, range);
removeRangeWithTxn(editor, sharedRoot, range);
const selection = editor.selection;
if (!selection) return;
handleCollapsedBreak(editor, sharedRoot, selection);
handleCollapsedBreakWithTxn(editor, sharedRoot, selection);
}
export function copyBlockText (sharedRoot: YSharedRoot, sourceBlock: YBlock, targetBlock: YBlock) {
@@ -197,8 +215,6 @@ export function turnToBlock<T extends BlockData> (sharedRoot: YSharedRoot, sourc
copyBlockText(sharedRoot, sourceBlock, newBlock);
transferChildren(sharedRoot, sourceBlock, newBlock);
const parent = getBlock(sourceBlock.get(YjsEditorKey.block_parent), sharedRoot);
if (!parent) return;
@@ -206,10 +222,16 @@ export function turnToBlock<T extends BlockData> (sharedRoot: YSharedRoot, sourc
const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot);
const index = parentChildren.toArray().findIndex((id) => id === sourceBlock.get(YjsEditorKey.block_id));
updateBlockParent(sharedRoot, newBlock, parent, index);
if (CONTAINER_BLOCK_TYPES.includes(type)) {
transferChildren(sharedRoot, sourceBlock, newBlock);
} else {
liftChildren(sharedRoot, sourceBlock, newBlock);
}
// delete source block
deleteBlock(sharedRoot, sourceBlock.get(YjsEditorKey.block_id));
updateBlockParent(sharedRoot, newBlock, parent, index);
}
function getSplitBlockOperations (sharedRoot: YSharedRoot, block: YBlock, offset: number): {
@@ -219,31 +241,21 @@ function getSplitBlockOperations (sharedRoot: YSharedRoot, block: YBlock, offset
const operations: (() => void)[] = [];
if (offset === 0) {
const yText = getText(block.get(YjsEditorKey.block_external_id), sharedRoot);
if (yText.length === 0) {
operations.push(() => {
turnToBlock(sharedRoot, block, BlockType.Paragraph, {});
operations.push(() => {
const type = block.get(YjsEditorKey.block_type);
const data = dataStringTOJson(block.get(YjsEditorKey.block_data));
const isList = ListBlockTypes.includes(type);
const newBlock = createBlock(sharedRoot, {
ty: isList ? type : BlockType.Paragraph,
data: isList ? data : {},
});
return { operations, select: false };
} else {
operations.push(() => {
const listBlockTypes = [BlockType.TodoListBlock, BlockType.BulletedListBlock, BlockType.NumberedListBlock];
const type = block.get(YjsEditorKey.block_type);
const data = dataStringTOJson(block.get(YjsEditorKey.block_data));
const isList = listBlockTypes.includes(type);
const newBlock = createBlock(sharedRoot, {
ty: isList ? type : BlockType.Paragraph,
data: isList ? data : {},
});
const parent = getBlock(block.get(YjsEditorKey.block_parent), sharedRoot);
const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot);
const index = parentChildren.toArray().findIndex((id) => id === block.get(YjsEditorKey.block_id));
const prevIndex = index <= 0 ? 0 : index;
const parent = getBlock(block.get(YjsEditorKey.block_parent), sharedRoot);
const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot);
const index = parentChildren.toArray().findIndex((id) => id === block.get(YjsEditorKey.block_id));
const prevIndex = index <= 0 ? 0 : index;
updateBlockParent(sharedRoot, newBlock, parent, prevIndex);
});
}
updateBlockParent(sharedRoot, newBlock, parent, prevIndex);
});
return { operations, select: true };
}
@@ -354,7 +366,6 @@ export function getAffectedBlocks (editor: YjsEditor, range: Range): {
endRange = Editor.range(editor, Editor.start(editor, endBlock[1]), end);
}
console.log('startBlock', startBlock, endBlock, middleBlocks, startRange, endRange);
return { startBlock, endBlock, middleBlocks, startRange, endRange };
}
@@ -365,7 +376,6 @@ function getTextIdFromSlateNode (node: Element) {
export function deleteSlateRangeInBlock (sharedRoot: YSharedRoot, editor: Editor, block: Element, range: BaseRange) {
const [start, end] = Editor.edges(editor, range);
console.log('deleteSlateRangeInBlock', start, end);
const relativeOffset = slatePointToRelativePosition(sharedRoot, editor, start);
const startPos = Y.createAbsolutePositionFromRelativePosition(relativeOffset.point, assertDocExists(sharedRoot));
const endRelativeOffset = slatePointToRelativePosition(sharedRoot, editor, end);
@@ -392,10 +402,8 @@ export function deleteRangeInBlock (
const yText = getText(textId, sharedRoot);
console.log('deleteRangeInBlock', start, end - start);
yText.delete(start, end - start);
console.log('deleteRangeInBlock', yText.toJSON());
}
export function mergeBlocks (
@@ -433,14 +441,7 @@ export function mergeBlocks (
function mergeBlockChildren (sharedRoot: YSharedRoot, sourceBlock: YBlock, targetBlock: YBlock) {
const targetType = targetBlock.get(YjsEditorKey.block_type);
if ([
BlockType.ToggleListBlock,
BlockType.TodoListBlock,
BlockType.Paragraph,
BlockType.QuoteBlock,
BlockType.BulletedListBlock,
BlockType.NumberedListBlock,
].includes(targetType)) {
if (CONTAINER_BLOCK_TYPES.includes(targetType)) {
transferChildren(sharedRoot, sourceBlock, targetBlock, 0);
} else {
liftChildren(sharedRoot, sourceBlock, targetBlock);
@@ -543,11 +544,13 @@ export function getSplitBlockType (block: YBlock) {
}
}
case BlockType.HeadingBlock:
case BlockType.QuoteBlock:
return BlockType.Paragraph;
default:
case BlockType.BulletedListBlock:
case BlockType.NumberedListBlock:
case BlockType.TodoListBlock:
return block.get(YjsEditorKey.block_type);
default:
return BlockType.Paragraph;
}
}
@@ -572,7 +575,7 @@ export function splitBlock (sharedRoot: YSharedRoot, block: YBlock, offset: numb
const blockType = block.get(YjsEditorKey.block_type);
if (blockType === BlockType.ToggleListBlock) {
if (blockType === BlockType.ToggleListBlock || blockType === BlockType.QuoteBlock) {
const data = dataStringTOJson(block.get(YjsEditorKey.block_data)) as ToggleListBlockData;
if (!data.collapsed) {
@@ -644,7 +647,6 @@ function deepCopyChildren (sharedRoot: YSharedRoot, sourceArray: Y.Array<string>
const sourceArraySorted = index === undefined ? sourceArray.toArray() : sourceArray.toArray().reverse();
console.log('deepCopyChildren', index);
sourceArraySorted.forEach((childId) => {
const sourceChild = getBlock(childId, sharedRoot);
@@ -723,7 +725,14 @@ export function getSelectionOrThrow (editor: YjsEditor, at?: BaseRange) {
return newAt;
}
export function getBlockEntry (editor: YjsEditor, point: Point) {
export function getBlockEntry (editor: YjsEditor, point?: Point) {
const { selection } = editor;
const at = point || (selection ? Editor.start(editor, selection) : null);
if (!at) {
throw new Error('Point not found');
}
const blockEntry = editor.above({
at: point,
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined,
@@ -736,7 +745,7 @@ export function getBlockEntry (editor: YjsEditor, point: Point) {
return blockEntry as NodeEntry<Element>;
}
export function handleNonParagraphBlockBackspace (sharedRoot: YSharedRoot, block: YBlock) {
export function handleNonParagraphBlockBackspaceAndEnterWithTxn (sharedRoot: YSharedRoot, block: YBlock) {
const operations: (() => void)[] = [];
operations.push(() => {
@@ -745,27 +754,109 @@ export function handleNonParagraphBlockBackspace (sharedRoot: YSharedRoot, block
executeOperations(sharedRoot, operations, 'turnToBlock');
}
export function handleShouldLiftBlockBackspace (editor: YjsEditor, sharedRoot: YSharedRoot, block: YBlock, point: Point) {
export function handleLiftBlockOnTabWithTxn (editor: YjsEditor, sharedRoot: YSharedRoot, block: YBlock, point: Point) {
const operations: (() => void)[] = [];
const [, path] = getBlockEntry(editor, point);
let newPath: number[] | undefined;
operations.push(() => {
newPath = liftEditorNode(editor, sharedRoot, block, point);
});
executeOperations(sharedRoot, operations, 'liftBlock');
if (!newPath) return;
const newPoint = {
path: [...newPath, ...point.path.slice(path.length)],
offset: point.offset,
};
// After the lift operation is complete, move the cursor to the start of the newly lifted block
Transforms.select(editor, newPoint);
}
export function handleLiftBlockOnBackspaceAndEnterWithTxn (editor: YjsEditor, sharedRoot: YSharedRoot, block: YBlock, point: Point) {
const operations: (() => void)[] = [];
const parent = getBlock(block.get(YjsEditorKey.block_parent), sharedRoot);
const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot);
const index = parentChildren.toArray().findIndex((id) => id === block.get(YjsEditorKey.block_id));
const [, path] = getBlockEntry(editor, point);
const hasNextSibling = index < parentChildren.length - 1;
const hasChildren = getChildrenArray(block.get(YjsEditorKey.block_children), sharedRoot).length > 0;
if (!hasChildren && !hasNextSibling) {
let newPath: number[] | undefined;
operations.push(() => {
liftEditorNode(editor, sharedRoot, block, point);
newPath = liftEditorNode(editor, sharedRoot, block, point);
});
executeOperations(sharedRoot, operations, 'deleteBlockBackward');
executeOperations(sharedRoot, operations, 'liftBlock');
if (!newPath) return;
const newPoint = {
path: [...newPath, ...point.path.slice(path.length)],
offset: point.offset,
};
// After the lift operation is complete, move the cursor to the start of the newly lifted block
Transforms.select(editor, newPoint);
return true;
}
return false;
}
export function handleMergeBlockBackward (editor: YjsEditor, node: Element, point: Point) {
export function handleIndentBlockWithTxn (editor: YjsEditor, sharedRoot: YSharedRoot, block: YBlock, point: Point) {
const operations: (() => void)[] = [];
const parent = getBlock(block.get(YjsEditorKey.block_parent), sharedRoot);
const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot);
const index = parentChildren.toArray().findIndex((id) => id === block.get(YjsEditorKey.block_id));
const parentType = parent.get(YjsEditorKey.block_type);
const [, path] = getBlockEntry(editor, point);
// Check if the parent block is a container block
if (!CONTAINER_BLOCK_TYPES.includes(parentType)) {
return false;
}
// Check if the block can be indented (not the first child)
if (index === 0) {
return false;
}
// Get the previous sibling
const previousSiblingId = parentChildren.get(index - 1);
const previousSibling = getBlock(previousSiblingId, sharedRoot);
if (!previousSibling) {
return false;
}
let newPath: number[] | undefined;
operations.push(() => {
newPath = indentEditorNode(editor, sharedRoot, block, point);
});
executeOperations(sharedRoot, operations, 'indentBlock');
if (!newPath) return;
const newPoint = {
path: [...newPath, ...point.path.slice(path.length)],
offset: point.offset,
};
console.log('newPath:', newPath, 'newPoint:', newPoint);
Transforms.select(editor, newPoint);
return true;
}
export function handleMergeBlockBackwardWithTxn (editor: YjsEditor, node: Element, point: Point) {
const operations: (() => void)[] = [];
const sharedRoot = getSharedRoot(editor);
@@ -799,7 +890,7 @@ export function handleMergeBlockBackward (editor: YjsEditor, node: Element, poin
executeOperations(sharedRoot, operations, 'deleteBlockBackward');
}
export function handleMergeBlockForward (editor: YjsEditor, node: Element, point: Point) {
export function handleMergeBlockForwardWithTxn (editor: YjsEditor, node: Element, point: Point) {
const operations: (() => void)[] = [];
const sharedRoot = getSharedRoot(editor);
@@ -850,8 +941,40 @@ export function liftEditorNode (editor: YjsEditor, sharedRoot: YSharedRoot, bloc
// Calculate the position where the new block should be after the lift operation
const nextPath = Path.next(parentPath);
// After the lift operation is complete, move the cursor to the start of the newly lifted block
Transforms.select(editor, Editor.start(editor, nextPath));
return nextPath;
}
export function indentEditorNode (editor: YjsEditor, sharedRoot: YSharedRoot, block: YBlock, point: Point) {
// Find the path of the current block
const [, path] = getBlockEntry(editor, point);
const index = path[path.length - 1];
console.log('indentEditorNode:', block.get(YjsEditorKey.block_id), 'at index', index, editor.children);
// Check if the block can be indented (not the first child)
if (index === 0) {
console.warn('Cannot indent: block is the first child');
return;
}
// Get the previous sibling's path
const previousSiblingPath = Path.previous(path);
const previousSibling = Editor.above(editor, {
at: [...previousSiblingPath, 0],
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined,
});
const [previousNode] = previousSibling as NodeEntry<Element>;
// This is to prevent errors caused by invalid paths when the original node is deleted during the indent process
Transforms.select(editor, Editor.start(editor, previousSiblingPath));
// Perform the indent operation on the Yjs document
indentBlock(sharedRoot, block);
const newPath = [...previousSiblingPath, previousNode.children.length];
return newPath;
}
export function liftBlock (sharedRoot: YSharedRoot, block: YBlock) {
@@ -888,7 +1011,55 @@ export function liftBlock (sharedRoot: YSharedRoot, block: YBlock) {
moveNode(sharedRoot, block, grandParent, parentIndex + 1);
}
export function indentBlock (sharedRoot: YSharedRoot, block: YBlock) {
const parentId = block.get(YjsEditorKey.block_parent);
const parent = getBlock(parentId, sharedRoot);
if (!parent) {
console.warn('Cannot indent block: parent not found');
return;
}
const parentChildrenArray = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot);
if (!parentChildrenArray) {
console.warn('Cannot indent block: parent children array not found');
return;
}
const blockIndex = parentChildrenArray.toArray().findIndex((id) => id === block.get(YjsEditorKey.block_id));
if (blockIndex === -1) {
console.warn('Cannot indent block: block not found in parent\'s children');
return;
}
if (blockIndex === 0) {
console.warn('Cannot indent block: block is the first child');
return;
}
const previousSiblingId = parentChildrenArray.get(blockIndex - 1);
const previousSibling = getBlock(previousSiblingId, sharedRoot);
if (!previousSibling) {
console.warn('Cannot indent block: previous sibling not found');
return;
}
const previousSiblingChildrenArray = getChildrenArray(previousSibling.get(YjsEditorKey.block_children), sharedRoot);
if (!previousSiblingChildrenArray) {
console.warn('Cannot indent block: previous sibling children array not found');
return;
}
return moveNode(sharedRoot, block, previousSibling, previousSiblingChildrenArray.length);
}
export function moveNode (sharedRoot: YSharedRoot, sourceBlock: YBlock, targetParent: YBlock, targetIndex: number) {
console.log('moveNode:', sourceBlock.get(YjsEditorKey.block_id), 'to', targetParent.get(YjsEditorKey.block_id), 'at index', targetIndex);
const copiedBlockId = deepCopyBlock(sharedRoot, sourceBlock);
if (!copiedBlockId) {
@@ -906,6 +1077,8 @@ export function moveNode (sharedRoot: YSharedRoot, sourceBlock: YBlock, targetPa
updateBlockParent(sharedRoot, copiedBlock, targetParent, targetIndex);
deleteBlock(sharedRoot, sourceBlock.get(YjsEditorKey.block_id));
return copiedBlockId;
}
export function deepCopyBlock (sharedRoot: YSharedRoot, sourceBlock: YBlock): string | null {
@@ -966,7 +1139,7 @@ export function appendEmptyParagraph (sharedRoot: YSharedRoot): string {
return newBlock.get(YjsEditorKey.block_id);
}
export function handleDeleteEntireDocument (editor: YjsEditor) {
export function handleDeleteEntireDocumentWithTxn (editor: YjsEditor) {
const sharedRoot = getSharedRoot(editor);
const operations = [() => {
editor.deselect();
@@ -985,4 +1158,30 @@ export function handleDeleteEntireDocument (editor: YjsEditor) {
executeOperations(sharedRoot, operations, 'deleteEntireDocument');
Transforms.select(editor, Editor.start(editor, [0]));
}
export function getNodeAtPath (children: Descendant[], path: Path): Descendant | null {
let currentNode: Descendant | null = null;
let currentChildren = children;
for (let i = 0; i < path.length; i++) {
const index = path[i];
if (index >= currentChildren.length) {
return null;
}
currentNode = currentChildren[index];
if (i === path.length - 1) {
return currentNode;
}
if (!Element.isElement(currentNode) || !currentNode.children) {
return null;
}
currentChildren = currentNode.children;
}
return currentNode;
}
@@ -3,14 +3,15 @@ import 'katex/dist/katex.min.css';
import { BlockMath, InlineMath } from 'react-katex';
import './index.css';
function KatexMath({ latex, isInline = false }: { latex: string; isInline?: boolean }) {
function KatexMath ({ latex, isInline = false }: { latex: string; isInline?: boolean }) {
return isInline ? (
<InlineMath math={latex} />
) : (
<BlockMath
renderError={(error) => {
return (
<div className='flex h-[51px] items-center justify-center'>
<div className="flex h-[51px] items-center justify-center">
{error.name}: {error.message}
</div>
);
@@ -14,6 +14,7 @@ const defaultInitialValue: Descendant[] = [];
function CollaborativeEditor ({ doc }: { doc: Y.Doc }) {
const context = useEditorContext();
const readSummary = context.readSummary;
const readOnly = context.readOnly;
const localOrigin = CollabOrigin.Local;
const [, setClock] = useState(0);
const onContentChange = useCallback(() => {
@@ -26,6 +27,7 @@ function CollaborativeEditor ({ doc }: { doc: Y.Doc }) {
withReact(
withYHistory(
withYjs(createEditor(), doc, {
readOnly: readOnly,
localOrigin,
readSummary,
onContentChange,
@@ -33,7 +35,7 @@ function CollaborativeEditor ({ doc }: { doc: Y.Doc }) {
),
),
) as YjsEditor),
[onContentChange, readSummary, doc, localOrigin],
[doc, readOnly, localOrigin, readSummary, onContentChange],
);
const [, setIsConnected] = useState(false);
@@ -1,6 +1,7 @@
import { useDecorate } from '@/components/editor/components/blocks/code/useDecorate';
import { Leaf } from '@/components/editor/components/leaf';
import { useEditorContext } from '@/components/editor/EditorContext';
import { useShortcuts } from '@/components/editor/shortcut.hooks';
import React, { Suspense, useCallback } from 'react';
import { NodeEntry, Range } from 'slate';
import { Editable, RenderElementProps, useSlate } from 'slate-react';
@@ -19,6 +20,11 @@ const EditorEditable = () => {
</Suspense>
);
}, []);
const {
onKeyDown,
} = useShortcuts(editor);
const onCompositionStart = useCallback(() => {
const { selection } = editor;
@@ -45,6 +51,7 @@ const EditorEditable = () => {
autoCorrect={'off'}
autoComplete={'off'}
onCompositionStart={onCompositionStart}
onKeyDown={onKeyDown}
/>
</>
);
@@ -0,0 +1,211 @@
import { mountEditor, moveCursor } from '@/components/editor/__tests__/mount';
import { DocumentTest, FromBlockJSON } from 'cypress/support/document';
let documentTest: DocumentTest;
const initialData: FromBlockJSON[] = [
{
type: 'paragraph',
data: {},
text: [{ insert: 'Outer paragraph' }],
children: [],
},
{
type: 'toggle_list',
data: {},
text: [{ insert: 'Toggle list' }],
children: [
{
type: 'paragraph',
data: {},
text: [{ insert: 'Nested paragraph 1' }],
children: [{
type: 'paragraph',
data: {},
text: [{ insert: 'Deeply nested paragraph' }],
children: [],
}],
},
{
type: 'toggle_list',
data: {},
text: [{ insert: 'Nested toggle list' }],
children: [
{
type: 'paragraph',
data: {},
text: [{ insert: 'Deeply nested paragraph' }],
children: [],
},
],
},
{
type: 'paragraph',
data: {},
text: [{ insert: 'Nested paragraph 2.' }],
children: [],
},
],
},
{
type: 'paragraph',
data: {},
text: [{ insert: 'Final outer paragraph' }],
children: [],
},
];
const initializeEditor = (data: FromBlockJSON[]) => {
documentTest = new DocumentTest();
documentTest.fromJSON(data);
mountEditor({ readOnly: false, doc: documentTest.doc });
cy.get('[role="textbox"]').should('exist');
};
const assertJSON = (expectedJSON: FromBlockJSON[]) => {
cy.wrap(null).then(() => {
const finalJSON = documentTest.toJSON();
expect(finalJSON).to.deep.equal(expectedJSON);
});
};
describe('Tab key behavior', () => {
beforeEach(() => {
cy.viewport(1280, 720);
Object.defineProperty(window.navigator, 'language', { value: 'en-US' });
initializeEditor(initialData);
const selector = '[role="textbox"]';
cy.get(selector).as('editor');
});
it('should indent paragraph when tab at start of paragraph', () => {
moveCursor(1, 0); // Move to 'Toggle list'
cy.get('@editor').realPress('Tab');
assertJSON([
{
...initialData[0],
children: [initialData[1]],
},
initialData[2],
]);
// Optional: Add visual regression test
cy.matchImageSnapshot('behavior/TabKeyBehavior/should-indent-paragraph');
});
it('should indent nested block at index 0/1 when tab at start', () => {
// Test 1: move to children[0]
moveCursor(2, 0); // Move to 'Nested paragraph 1'
cy.get('@editor').realPress('Tab');
assertJSON(initialData);
// Test 2: move to children[1]
moveCursor(4, 0); // Move to 'Deeply nested paragraph'
cy.get('@editor').realPress('Tab');
assertJSON([
initialData[0],
{
...initialData[1],
children: [
{
...initialData[1].children[0],
children: [initialData[1].children[0].children[0], initialData[1].children[1]],
},
initialData[1].children[2],
],
},
initialData[2],
]);
// Optional: Add visual regression test
cy.matchImageSnapshot('behavior/TabKeyBehavior/should-indent-nested-block-at-index-0-1');
});
it('should not indent at start of document', () => {
moveCursor(0, 0);
cy.get('@editor').realPress('Tab');
assertJSON(initialData);
// Optional: Add visual regression test
cy.matchImageSnapshot('behavior/TabKeyBehavior/should-not-indent-at-start-of-document');
});
it('should not indent at start of nested block', () => {
moveCursor(2, 0);
cy.get('@editor').realPress('Tab');
assertJSON(initialData);
// Optional: Add visual regression test
cy.matchImageSnapshot('behavior/TabKeyBehavior/should-not-indent-at-start-of-nested-block');
});
});
describe('Shift+Tab key behavior', () => {
beforeEach(() => {
cy.viewport(1280, 720);
Object.defineProperty(window.navigator, 'language', { value: 'en-US' });
initializeEditor(initialData);
const selector = '[role="textbox"]';
cy.get(selector).as('editor');
});
it('should outdent deeply nested paragraph when shift+tab at start', () => {
moveCursor(3, 3); // Move to 'Deeply nested paragraph'
cy.get('@editor').realPress(['Shift', 'Tab']);
assertJSON([
initialData[0],
{
...initialData[1],
children: [
{
...initialData[1].children[0],
children: [],
},
initialData[1].children[0].children[0],
initialData[1].children[1],
initialData[1].children[2],
],
},
initialData[2],
]);
//
// Optional: Add visual regression test
cy.matchImageSnapshot('behavior/ShiftTabKeyBehavior/should-outdent-deeply-nested-paragraph');
});
it('should outdent Nested toggle list when shift+tab at start', () => {
moveCursor(4, 1); // Move to 'Nested toggle list'
cy.get('@editor').realPress(['Shift', 'Tab']);
assertJSON([
initialData[0],
{
...initialData[1],
children: [
initialData[1].children[0],
initialData[1].children[2],
],
},
initialData[1].children[1],
initialData[2],
]);
//
// Optional: Add visual regression test
cy.matchImageSnapshot('behavior/ShiftTabKeyBehavior/should-outdent-nested-toggle-list');
});
it('should not outdent at top level', () => {
moveCursor(0, 0); // Move to 'Outer paragraph'
cy.get('@editor').realPress(['Shift', 'Tab']);
assertJSON(initialData);
moveCursor(7, 0); // Move to 'Final outer paragraph'
cy.get('@editor').realPress(['Shift', 'Tab']);
assertJSON(initialData);
// Optional: Add visual regression test
cy.matchImageSnapshot('behavior/ShiftTabKeyBehavior/should-not-outdent-at-top-level');
});
});
@@ -54,7 +54,7 @@ describe('<Paragraph />', () => {
cy.get('[role="textbox"]').should('exist');
cy.get('[role="textbox"]').type('{movetostart}').type('{rightarrow}'.repeat(10))
.type(' New text at the `world` middle ');
.type(` New text at the 'world' middle `);
cy.get('[role="textbox"]').type('{movetoend}').type('{leftarrow}'.repeat(6)).type('{backspace}'.repeat(4));
cy.wrap(null).then(() => {
@@ -65,7 +65,7 @@ describe('<Paragraph />', () => {
text: [
{ insert: 'Hello, ' },
{
insert: 'wor New text at the `world` middle ld!',
insert: `wor New text at the 'world' middle ld!`,
attributes: {
italic: true,
underline: true,
@@ -28,19 +28,25 @@ export const moveToLineStart = (lineIndex: number) => {
}
};
export const moveAndEnter = (lineIndex: number, moveCount: number) => {
export const moveCursor = (lineIndex: number, charIndex: number) => {
moveToLineStart(lineIndex);
// Move the cursor with right arrow key and batch the movement
const batchSize = 5;
const batches = Math.ceil(moveCount / batchSize);
const batches = Math.ceil(charIndex / batchSize);
for (let i = 0; i < batches; i++) {
const remainingMoves = Math.min(batchSize, moveCount - i * batchSize);
const remainingMoves = Math.min(batchSize, charIndex - i * batchSize);
cy.get('@targetBlock')
.type('{rightarrow}'.repeat(remainingMoves))
.wait(50);
}
};
export const moveAndEnter = (lineIndex: number, moveCount: number) => {
moveToLineStart(lineIndex);
// Move the cursor with right arrow key and batch the movement
moveCursor(lineIndex, moveCount);
cy.get('@targetBlock').type('{enter}');
};
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff. Show more