Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c956afabd | ||
|
|
f9fbf62283 | ||
|
|
5f1f536181 | ||
|
|
ba59514464 | ||
|
|
6bb6750be7 | ||
|
|
19a3df6d47 | ||
|
|
87408fd2a9 | ||
|
|
9ee39f45c9 | ||
|
|
a763304386 | ||
|
|
f8ce501b53 | ||
|
|
54d449647c | ||
|
|
552fd39d74 | ||
|
|
01d73f2753 | ||
|
|
75da496128 |
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,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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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}');
|
||||
};
|
||||