diff --git a/.github/workflows/flutter_ci.yaml b/.github/workflows/flutter_ci.yaml index aa3dd6b90980..b64a51a00e31 100644 --- a/.github/workflows/flutter_ci.yaml +++ b/.github/workflows/flutter_ci.yaml @@ -23,7 +23,7 @@ on: env: CARGO_TERM_COLOR: always - FLUTTER_VERSION: "3.10.4" + FLUTTER_VERSION: "3.13.9" RUST_TOOLCHAIN: "1.70" CARGO_MAKE_VERSION: "0.36.6" diff --git a/.github/workflows/mobile_ci.yaml b/.github/workflows/mobile_ci.yaml index 43c06aba9f83..17657eafadfd 100644 --- a/.github/workflows/mobile_ci.yaml +++ b/.github/workflows/mobile_ci.yaml @@ -18,7 +18,7 @@ on: - "!frontend/appflowy_tauri/**" env: - FLUTTER_VERSION: "3.10.1" + FLUTTER_VERSION: "3.13.9" RUST_TOOLCHAIN: "1.70" concurrency: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8009aeb86d6..243062ac2b81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: - "*" env: - FLUTTER_VERSION: "3.10.1" + FLUTTER_VERSION: "3.13.9" RUST_TOOLCHAIN: "1.70" jobs: @@ -59,7 +59,6 @@ jobs: with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} - cache: true - name: Install Rust toolchain uses: actions-rs/toolchain@v1 @@ -70,11 +69,6 @@ jobs: components: rustfmt profile: minimal - - uses: Swatinem/rust-cache@v2 - with: - prefix-key: appflowy-lib-cache - key: ${{ matrix.job.os }}-${{ matrix.job.target }} - - name: Install prerequisites working-directory: frontend run: | @@ -151,7 +145,6 @@ jobs: with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} - cache: true - name: Install Rust toolchain uses: actions-rs/toolchain@v1 @@ -162,11 +155,6 @@ jobs: components: rustfmt profile: minimal - - uses: Swatinem/rust-cache@v2 - with: - prefix-key: appflowy-lib-cache - key: ${{ matrix.job.os }}-${{ matrix.job.target }} - - name: Install prerequisites working-directory: frontend run: | @@ -257,7 +245,6 @@ jobs: with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} - cache: true - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -266,11 +253,6 @@ jobs: targets: ${{ matrix.job.targets }} components: rustfmt - - uses: Swatinem/rust-cache@v2 - with: - prefix-key: appflowy-lib-cache - key: ${{ matrix.job.os }}-${{ matrix.job.target }} - - name: Install prerequisites working-directory: frontend run: | @@ -366,7 +348,6 @@ jobs: with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} - cache: true - name: Install Rust toolchain uses: actions-rs/toolchain@v1 @@ -377,11 +358,6 @@ jobs: components: rustfmt profile: minimal - - uses: Swatinem/rust-cache@v2 - with: - prefix-key: appflowy-lib-cache - key: ${{ matrix.job.os }}-${{ matrix.job.target }} - - name: Install prerequisites working-directory: frontend run: | diff --git a/.github/workflows/rust_coverage.yml b/.github/workflows/rust_coverage.yml index 2121fcf6ae25..7d36e625e582 100644 --- a/.github/workflows/rust_coverage.yml +++ b/.github/workflows/rust_coverage.yml @@ -11,7 +11,7 @@ on: env: CARGO_TERM_COLOR: always - FLUTTER_VERSION: "3.10.1" + FLUTTER_VERSION: "3.13.9" RUST_TOOLCHAIN: "1.70" jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b904f260ac8..3de85ea06062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Release Notes +## Version 0.3.8 - 11/13/2023 + +### New Features +- Support hiding any stack in a board +- Support customizing page icons in menu +- Display visual hint when card contains notes +- Quick action for adding new stack to a board +- Support more ways of inserting page references in documents +- Shift + click on a checkbox to power toggle its children + +### Bug fixes +- Improved color of the "Share"-button text +- Text overflow issue in Calendar properties +- Default font (Roboto) added to application +- Placeholder added for the editor inside a Card +- Toggle notifications in settings have been fixed +- Dialog for linking board/grid/calendar opens in correct position +- Quick add Card in Board at top, correctly adds a new Card at the top + +## Version 0.3.7 - 10/30/2023 + +### New Features +- Support showing checklist items inline in row page. +- Support inserting date from slash menu. +- Support renaming a stack directly by clicking on the stack name. +- Show the detailed reminder content in the notification center. +- Save card order in Board view. +- Allow to hide the ungrouped stack. +- Segmented the checklist progress bar. + +### Bug fixes +- Optimize side panel animation. +- Fix calendar with hidden date or title doesn't show options correctly. +- Fix the horizontal scroll bar disappears in Grid view. +- Improve setting tab UI in Grid view. +- Improve theme of the code block. +- Fix some UI issues. + ## Version 0.3.6 - 10/16/2023 ### New Features @@ -7,8 +45,7 @@ - Added Ukrainian language. - Support auto-hiding sidebar feature, ensuring a streamlined view even when resizing to a smaller window. - Support toggling the notifitcation on/off. -- Added Lemonade theme. - +- Added Lemonade theme. ### Bug fixes - Improve Vietnamese translations. diff --git a/frontend/.vscode/tasks.json b/frontend/.vscode/tasks.json index 3c0301838479..a6027d9d1769 100644 --- a/frontend/.vscode/tasks.json +++ b/frontend/.vscode/tasks.json @@ -314,13 +314,5 @@ "cwd": "${workspaceFolder}/appflowy_flutter" } }, - { - "label": "AF: Generate AppFlowyEnv", - "type": "shell", - "command": "dart run build_runner build --delete-conflicting-outputs", - "options": { - "cwd": "${workspaceFolder}/appflowy_flutter/packages/appflowy_backend" - } - } ] } diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml index 14e62b157d74..052ca05c4934 100644 --- a/frontend/Makefile.toml +++ b/frontend/Makefile.toml @@ -25,7 +25,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true CARGO_MAKE_CRATE_FS_NAME = "dart_ffi" CARGO_MAKE_CRATE_NAME = "dart-ffi" LIB_NAME = "dart_ffi" -CURRENT_APP_VERSION = "0.3.7" +CURRENT_APP_VERSION = "0.3.8" FLUTTER_DESKTOP_FEATURES = "dart,rev-sqlite" PRODUCT_NAME = "AppFlowy" # CRATE_TYPE: https://doc.rust-lang.org/reference/linkage.html diff --git a/frontend/appflowy_flutter/README.md b/frontend/appflowy_flutter/README.md index 8ae646031f69..116cd63f22b5 100644 --- a/frontend/appflowy_flutter/README.md +++ b/frontend/appflowy_flutter/README.md @@ -1,7 +1,7 @@

AppFlowy_Flutter

- - + +
> Documentation for Contributors diff --git a/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/LICENSE.txt b/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/LICENSE.txt new file mode 100644 index 000000000000..75b52484ea47 --- /dev/null +++ b/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf b/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf new file mode 100644 index 000000000000..61e5303325a1 Binary files /dev/null and b/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf differ diff --git a/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf b/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf new file mode 100644 index 000000000000..6df2b2536030 Binary files /dev/null and b/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf differ diff --git a/frontend/appflowy_flutter/dev.env b/frontend/appflowy_flutter/dev.env index b00d233a2fbb..f51f1cbd9e20 100644 --- a/frontend/appflowy_flutter/dev.env +++ b/frontend/appflowy_flutter/dev.env @@ -1,23 +1,45 @@ # Initial Setup + # 1. Copy the dev.env file to .env: -# cp dev.env .env -# 2. Alternatively, you can generate the .env file using the "Generate Env File" task in VSCode. +# cp dev.env .env +# Update the environment parameters as needed. + +# 2. Generate the env.dart from this .env file: +# You can use the "Generate Env File" task in VSCode. +# Alternatively, execute the following commands: +# cd appflowy_flutter +# dart run build_runner clean && dart run build_runner build --delete-conflicting-outputs + -# Configuring Cloud Type -# This configuration file is used to specify the cloud type and the necessary configurations for each cloud type. The available options are: +# Cloud Type Configuration +# Use this configuration file to specify the cloud type and its associated settings. The available cloud types are: # Local: 0 # Supabase: 1 # AppFlowy Cloud: 2 - +# By default, it's set to Local. CLOUD_TYPE=0 # Supabase Configuration -# If you're using Supabase (CLOUD_TYPE=1), you need to provide the following configurations: -SUPABASE_URL=replace-with-your-supabase-url -SUPABASE_ANON_KEY=replace-with-your-supabase-key +# If using Supabase (CLOUD_TYPE=1), provide the following details: +SUPABASE_URL= +SUPABASE_ANON_KEY= # AppFlowy Cloud Configuration -# If you're using AppFlowy Cloud (CLOUD_TYPE=2), you need to provide the following configurations: -APPFLOWY_CLOUD_BASE_URL=replace-with-your-appflowy-cloud-url -APPFLOWY_CLOUD_WS_BASE_URL=replace-with-your-appflowy-cloud-ws-url -APPFLOWY_CLOUD_GOTRUE_URL=replace-with-your-appflowy-cloud-gotrue-url \ No newline at end of file +# If using AppFlowy Cloud (CLOUD_TYPE=2), provide the following details: +# For instance: +# APPFLOWY_CLOUD_BASE_URL=https://xxxxxxxxx +# APPFLOWY_CLOUD_WS_BASE_URL=wss://xxxxxxxxx +# APPFLOWY_CLOUD_GOTRUE_URL=https://xxxxxxxxx +# +# When using localhost for development, you must run AppFlowy Cloud locally +# first. Plese Please follow the instructions below: +# https://github.com/AppFlowy-IO/AppFlowy-Cloud#development +# +# After running AppFlowy Cloud locally, you can use the following settings: +# APPFLOWY_CLOUD_BASE_URL=http://localhost:8000 +# APPFLOWY_CLOUD_WS_BASE_URL=ws://localhost:8000/ws +# APPFLOWY_CLOUD_GOTRUE_URL=http://localhost:9998 + +APPFLOWY_CLOUD_BASE_URL= +APPFLOWY_CLOUD_WS_BASE_URL= +APPFLOWY_CLOUD_GOTRUE_URL= diff --git a/frontend/appflowy_flutter/integration_test/board/board_add_row_test.dart b/frontend/appflowy_flutter/integration_test/board/board_add_row_test.dart new file mode 100644 index 000000000000..fe58e3836bb9 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/board/board_add_row_test.dart @@ -0,0 +1,104 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database_view/board/presentation/widgets/board_column_header.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:appflowy_board/appflowy_board.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../util/util.dart'; + +const defaultFirstCardName = 'Card 1'; +const defaultLastCardName = 'Card 3'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('board add row test', () { + testWidgets('Add card from header', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + await tester.createNewPageWithName(layout: ViewLayoutPB.Board); + + final findFirstCard = find.descendant( + of: find.byType(AppFlowyGroupCard), + matching: find.byType(FlowyText), + ); + + FlowyText firstCardText = tester.firstWidget(findFirstCard); + expect(firstCardText.text, defaultFirstCardName); + + await tester.tap( + find + .descendant( + of: find.byType(BoardColumnHeader), + matching: find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg == FlowySvgs.add_s, + ), + ) + .at(1), + ); + await tester.pumpAndSettle(); + + const newCardName = 'Card 4'; + await tester.enterText( + find.descendant( + of: find.byType(IntrinsicHeight), + matching: find.byType(TextField), + ), + newCardName, + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(AppFlowyBoard)); + await tester.pumpAndSettle(); + + firstCardText = tester.firstWidget(findFirstCard); + expect(firstCardText.text, newCardName); + }); + + testWidgets('Add card from footer', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + await tester.createNewPageWithName(layout: ViewLayoutPB.Board); + + final findLastCard = find.descendant( + of: find.byType(AppFlowyGroupCard), + matching: find.byType(FlowyText), + ); + + FlowyText? lastCardText = + tester.widgetList(findLastCard).last as FlowyText; + expect(lastCardText.text, defaultLastCardName); + + await tester.tap( + find + .descendant( + of: find.byType(AppFlowyGroupFooter), + matching: find.byType(FlowySvg), + ) + .at(1), + ); + await tester.pumpAndSettle(); + + const newCardName = 'Card 4'; + await tester.enterText( + find.descendant( + of: find.byType(IntrinsicHeight), + matching: find.byType(TextField), + ), + newCardName, + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(AppFlowyBoard)); + await tester.pumpAndSettle(); + + lastCardText = tester.widgetList(findLastCard).last as FlowyText; + expect(lastCardText.text, newCardName); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/board/board_hide_groups_test.dart b/frontend/appflowy_flutter/integration_test/board/board_hide_groups_test.dart new file mode 100644 index 000000000000..46d91766d983 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/board/board_hide_groups_test.dart @@ -0,0 +1,98 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database_view/board/presentation/widgets/board_column_header.dart'; +import 'package:appflowy/plugins/database_view/board/presentation/widgets/board_hidden_groups.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../util/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('board hide groups test', () { + testWidgets('expand/collapse hidden groups', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + await tester.createNewPageWithName(layout: ViewLayoutPB.Board); + + final collapseFinder = find.byFlowySvg(FlowySvgs.pull_left_outlined_s); + final expandFinder = find.byFlowySvg(FlowySvgs.hamburger_s_s); + + // Is expanded by default + expect(collapseFinder, findsOneWidget); + expect(expandFinder, findsNothing); + + // Collapse hidden groups + await tester.tap(collapseFinder); + await tester.pumpAndSettle(); + + // Is collapsed + expect(collapseFinder, findsNothing); + expect(expandFinder, findsOneWidget); + + // Expand hidden groups + await tester.tap(expandFinder); + await tester.pumpAndSettle(); + + // Is expanded + expect(collapseFinder, findsOneWidget); + expect(expandFinder, findsNothing); + }); + + testWidgets('hide first group, and show it again', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + await tester.createNewPageWithName(layout: ViewLayoutPB.Board); + + // Tap the options of the first group + final optionsFinder = find + .descendant( + of: find.byType(BoardColumnHeader), + matching: find.byFlowySvg(FlowySvgs.details_horizontal_s), + ) + .first; + + await tester.tap(optionsFinder); + await tester.pumpAndSettle(); + + // Tap the hide option + await tester.tap(find.byFlowySvg(FlowySvgs.hide_s)); + await tester.pumpAndSettle(); + + int shownGroups = + tester.widgetList(find.byType(BoardColumnHeader)).length; + + // We still show Doing, Done, No Status + expect(shownGroups, 3); + + final hiddenCardFinder = find.byType(HiddenGroupCard); + await tester.hoverOnWidget(hiddenCardFinder); + await tester.tap(find.byFlowySvg(FlowySvgs.show_m)); + await tester.pumpAndSettle(); + + shownGroups = tester.widgetList(find.byType(BoardColumnHeader)).length; + expect(shownGroups, 4); + }); + }); +} + +extension FlowySvgFinder on CommonFinders { + Finder byFlowySvg(FlowySvgData svg) => _FlowySvgFinder(svg); +} + +class _FlowySvgFinder extends MatchFinder { + _FlowySvgFinder(this.svg); + + final FlowySvgData svg; + + @override + String get description => 'flowy_svg "$svg"'; + + @override + bool matches(Element candidate) { + final Widget widget = candidate.widget; + return widget is FlowySvg && widget.svg == svg; + } +} diff --git a/frontend/appflowy_flutter/integration_test/board/board_row_test.dart b/frontend/appflowy_flutter/integration_test/board/board_row_test.dart index cdb5359f078d..46f9d969719b 100644 --- a/frontend/appflowy_flutter/integration_test/board/board_row_test.dart +++ b/frontend/appflowy_flutter/integration_test/board/board_row_test.dart @@ -1,11 +1,14 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database_view/widgets/card/card.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:appflowy_board/appflowy_board.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../util/util.dart'; +import '../util/database_test_op.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -52,5 +55,56 @@ void main() { await tester.tapButtonWithName(LocaleKeys.button_duplicate.tr()); expect(find.textContaining(name, findRichText: true), findsNWidgets(2)); }); + + testWidgets('add new group', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + await tester.createNewPageWithName(layout: ViewLayoutPB.Board); + + // assert number of groups + tester.assertNumberOfGroups(4); + + // scroll the board horizontally to ensure add new group button appears + await tester.scrollBoardToEnd(); + + // assert and click on add new group button + tester.assertNewGroupTextField(false); + await tester.tapNewGroupButton(); + tester.assertNewGroupTextField(true); + + // enter new group name and submit + await tester.enterNewGroupName('needs design', submit: true); + + // assert number of groups has increased + tester.assertNumberOfGroups(5); + + // assert text field has disappeared + await tester.scrollBoardToEnd(); + tester.assertNewGroupTextField(false); + + // click on add new group button + await tester.tapNewGroupButton(); + tester.assertNewGroupTextField(true); + + // type some things + await tester.enterNewGroupName('needs planning', submit: false); + + // click on clear button and assert empty contents + await tester.clearNewGroupTextField(); + + // press escape to cancel + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + tester.assertNewGroupTextField(false); + + // click on add new group button + await tester.tapNewGroupButton(); + tester.assertNewGroupTextField(true); + + // press elsewhere to cancel + await tester.tap(find.byType(AppFlowyBoard)); + await tester.pumpAndSettle(); + tester.assertNewGroupTextField(false); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/board/board_test_runner.dart b/frontend/appflowy_flutter/integration_test/board/board_test_runner.dart index ebed667fb2b8..d95c1cbdd7b4 100644 --- a/frontend/appflowy_flutter/integration_test/board/board_test_runner.dart +++ b/frontend/appflowy_flutter/integration_test/board/board_test_runner.dart @@ -1,10 +1,12 @@ import 'package:integration_test/integration_test.dart'; import 'board_row_test.dart' as board_row_test; +import 'board_add_row_test.dart' as board_add_row_test; void startTesting() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // Board integration tests board_row_test.main(); + board_add_row_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/database_field_settings_test.dart b/frontend/appflowy_flutter/integration_test/database_field_settings_test.dart index b22993821103..909d13b49a45 100644 --- a/frontend/appflowy_flutter/integration_test/database_field_settings_test.dart +++ b/frontend/appflowy_flutter/integration_test/database_field_settings_test.dart @@ -1,5 +1,5 @@ import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart'; -import 'package:appflowy/plugins/database_view/tar_bar/tar_bar_add_button.dart'; +import 'package:appflowy/plugins/database_view/tab_bar/tab_bar_add_button.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pbenum.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; diff --git a/frontend/appflowy_flutter/integration_test/database_row_page_test.dart b/frontend/appflowy_flutter/integration_test/database_row_page_test.dart index 62d5c81be31e..9a771eb876f9 100644 --- a/frontend/appflowy_flutter/integration_test/database_row_page_test.dart +++ b/frontend/appflowy_flutter/integration_test/database_row_page_test.dart @@ -42,7 +42,6 @@ void main() { await tester.hoverRowBanner(); await tester.openEmojiPicker(); - await tester.switchToEmojiList(); await tester.tapEmoji('😀'); // After select the emoji, the EmojiButton will show up @@ -60,12 +59,10 @@ void main() { await tester.openFirstRowDetailPage(); await tester.hoverRowBanner(); await tester.openEmojiPicker(); - await tester.switchToEmojiList(); await tester.tapEmoji('😀'); // Update existing selected emoji await tester.tapButton(find.byType(EmojiButton)); - await tester.switchToEmojiList(); await tester.tapEmoji('😅'); // The emoji already displayed in the row banner @@ -89,7 +86,6 @@ void main() { await tester.openFirstRowDetailPage(); await tester.hoverRowBanner(); await tester.openEmojiPicker(); - await tester.switchToEmojiList(); await tester.tapEmoji('😀'); // Remove the emoji diff --git a/frontend/appflowy_flutter/integration_test/database_share_test.dart b/frontend/appflowy_flutter/integration_test/database_share_test.dart index bd42847f82c1..3582a6c74625 100644 --- a/frontend/appflowy_flutter/integration_test/database_share_test.dart +++ b/frontend/appflowy_flutter/integration_test/database_share_test.dart @@ -34,7 +34,7 @@ void main() { false, false, false, - false + false, ]; for (final (index, content) in checkboxCells.indexed) { await tester.assertCheckboxCell( @@ -54,7 +54,7 @@ void main() { '10', '11', '12', - '' + '', ]; for (final (index, content) in numberCells.indexed) { await tester.assertCellContent( @@ -152,7 +152,7 @@ void main() { 'Jun 16, 2023', '', '', - '' + '', ]; for (final (index, content) in dateCells.indexed) { await tester.assertDateCellInGrid( diff --git a/frontend/appflowy_flutter/integration_test/database_view_test.dart b/frontend/appflowy_flutter/integration_test/database_view_test.dart index 88c04eb45c52..740d1232ec5d 100644 --- a/frontend/appflowy_flutter/integration_test/database_view_test.dart +++ b/frontend/appflowy_flutter/integration_test/database_view_test.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/plugins/database_view/tar_bar/tar_bar_add_button.dart'; +import 'package:appflowy/plugins/database_view/tab_bar/tab_bar_add_button.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/frontend/appflowy_flutter/integration_test/document/document_copy_and_paste_test.dart b/frontend/appflowy_flutter/integration_test/document/document_copy_and_paste_test.dart index c4c34841926e..902255055c87 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_copy_and_paste_test.dart +++ b/frontend/appflowy_flutter/integration_test/document/document_copy_and_paste_test.dart @@ -219,7 +219,7 @@ void main() { expect(node.delta!.toJson(), [ { 'insert': text, - 'attributes': {'href': url} + 'attributes': {'href': url}, } ]); }, diff --git a/frontend/appflowy_flutter/integration_test/document/document_inline_page_reference_test.dart b/frontend/appflowy_flutter/integration_test/document/document_inline_page_reference_test.dart new file mode 100644 index 000000000000..49d067f248a3 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/document/document_inline_page_reference_test.dart @@ -0,0 +1,136 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../util/keyboard.dart'; +import '../util/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('insert inline document reference', () { + testWidgets('insert by slash menu', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + final name = await createDocumentToReference(tester); + + await tester.editor.tapLineOfEditorAt(0); + await tester.pumpAndSettle(); + + await triggerReferenceDocumentBySlashMenu(tester); + + // Search for prefix of document + await enterDocumentText(tester); + + // Select result + final optionFinder = find.descendant( + of: find.byType(LinkToPageMenu), + matching: find.text(name), + ); + + await tester.tap(optionFinder); + await tester.pumpAndSettle(); + + final mentionBlock = find.byType(MentionPageBlock); + expect(mentionBlock, findsOneWidget); + }); + + testWidgets('insert by `[[` character shortcut', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + final name = await createDocumentToReference(tester); + + await tester.editor.tapLineOfEditorAt(0); + await tester.pumpAndSettle(); + + await tester.ime.insertText('[['); + await tester.pumpAndSettle(); + + // Select result + await tester.editor.tapAtMenuItemWithName(name); + await tester.pumpAndSettle(); + + final mentionBlock = find.byType(MentionPageBlock); + expect(mentionBlock, findsOneWidget); + }); + + testWidgets('insert by `+` character shortcut', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + final name = await createDocumentToReference(tester); + + await tester.editor.tapLineOfEditorAt(0); + await tester.pumpAndSettle(); + + await tester.ime.insertText('+'); + await tester.pumpAndSettle(); + + // Select result + await tester.editor.tapAtMenuItemWithName(name); + await tester.pumpAndSettle(); + + final mentionBlock = find.byType(MentionPageBlock); + expect(mentionBlock, findsOneWidget); + }); + }); +} + +Future createDocumentToReference(WidgetTester tester) async { + final name = 'document_${uuid()}'; + + await tester.createNewPageWithName( + name: name, + layout: ViewLayoutPB.Document, + openAfterCreated: false, + ); + + // This is a workaround since the openAfterCreated + // option does not work in createNewPageWithName method + await tester.tap(find.byType(SingleInnerViewItem).first); + await tester.pumpAndSettle(); + + return name; +} + +Future triggerReferenceDocumentBySlashMenu(WidgetTester tester) async { + await tester.editor.showSlashMenu(); + await tester.pumpAndSettle(); + + // Search for referenced document action + await enterDocumentText(tester); + + // Select item + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + LogicalKeyboardKey.enter, + ], + tester: tester, + ); + + await tester.pumpAndSettle(); +} + +Future enterDocumentText(WidgetTester tester) async { + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + LogicalKeyboardKey.keyD, + LogicalKeyboardKey.keyO, + LogicalKeyboardKey.keyC, + LogicalKeyboardKey.keyU, + LogicalKeyboardKey.keyM, + LogicalKeyboardKey.keyE, + LogicalKeyboardKey.keyN, + LogicalKeyboardKey.keyT, + ], + tester: tester, + ); + await tester.pumpAndSettle(); +} diff --git a/frontend/appflowy_flutter/integration_test/document/document_test_runner.dart b/frontend/appflowy_flutter/integration_test/document/document_test_runner.dart index 2e9ca77f5ebb..42462c26585a 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_test_runner.dart +++ b/frontend/appflowy_flutter/integration_test/document/document_test_runner.dart @@ -16,6 +16,8 @@ import 'document_with_inline_page_test.dart' as document_with_inline_page_test; 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; +import 'document_inline_page_reference_test.dart' + as document_inline_page_reference_test; void startTesting() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -35,4 +37,5 @@ void startTesting() { document_text_direction_test.main(); document_option_action_test.main(); document_with_image_block_test.main(); + document_inline_page_reference_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/document/document_with_cover_image_test.dart b/frontend/appflowy_flutter/integration_test/document/document_with_cover_image_test.dart index 5bcf4b7b43a4..e92b849cd530 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_with_cover_image_test.dart +++ b/frontend/appflowy_flutter/integration_test/document/document_with_cover_image_test.dart @@ -1,4 +1,9 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/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'; @@ -17,7 +22,6 @@ void main() { // Hover over cover toolbar to show 'Add Cover' and 'Add Icon' buttons await tester.editor.hoverOnCoverToolbar(); - tester.expectToSeePluginAddCoverAndIconButton(); // Insert a document cover await tester.editor.tapOnAddCover(); @@ -53,15 +57,10 @@ void main() { await tester.initializeAppFlowy(); await tester.tapGoButton(); - tester.expectToSeeDocumentIcon(null); - - // Hover over cover toolbar to show the 'Add Cover' and 'Add Icon' buttons - await tester.editor.hoverOnCoverToolbar(); - tester.expectToSeePluginAddCoverAndIconButton(); + tester.expectToSeeDocumentIcon('⭐️'); // Insert a document icon - await tester.editor.tapAddIconButton(); - await tester.switchToEmojiList(); + await tester.editor.tapGettingStartedIcon(); await tester.tapEmoji('😀'); tester.expectToSeeDocumentIcon('😀'); @@ -73,13 +72,11 @@ void main() { // Add the icon back for further testing await tester.editor.hoverOnCoverToolbar(); await tester.editor.tapAddIconButton(); - await tester.switchToEmojiList(); await tester.tapEmoji('😀'); tester.expectToSeeDocumentIcon('😀'); // Change the document icon await tester.editor.tapOnIconWidget(); - await tester.switchToEmojiList(); await tester.tapEmoji('😅'); tester.expectToSeeDocumentIcon('😅'); @@ -93,19 +90,15 @@ void main() { await tester.initializeAppFlowy(); await tester.tapGoButton(); - tester.expectToSeeDocumentIcon(null); + tester.expectToSeeDocumentIcon('⭐️'); tester.expectToSeeNoDocumentCover(); - // Hover over cover toolbar to show the 'Add Cover' and 'Add Icon' buttons - await tester.editor.hoverOnCoverToolbar(); - tester.expectToSeePluginAddCoverAndIconButton(); - // Insert a document icon - await tester.editor.tapAddIconButton(); - await tester.switchToEmojiList(); + await tester.editor.tapGettingStartedIcon(); await tester.tapEmoji('😀'); // Insert a document cover + await tester.editor.hoverOnCoverToolbar(); await tester.editor.tapOnAddCover(); // Expect to see the icon and cover at the same time @@ -116,5 +109,48 @@ void main() { await tester.editor.hoverOnCoverToolbar(); tester.expectToSeeEmptyDocumentHeaderToolbar(); }); + + testWidgets('shuffle icon', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + await tester.editor.tapGettingStartedIcon(); + + // click the shuffle button + await tester.tapButton( + find.byTooltip(LocaleKeys.emoji_random.tr()), + ); + tester.expectDocumentIconNotNull(); + }); + + testWidgets('change skin tone', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + await tester.editor.tapGettingStartedIcon(); + + final searchEmojiTextField = find.byWidgetPredicate( + (widget) => + widget is TextField && + widget.decoration!.hintText == LocaleKeys.emoji_search.tr(), + ); + await tester.enterText( + searchEmojiTextField, + 'hand', + ); + + // change skin tone + await tester.editor.changeEmojiSkinTone(EmojiSkinTone.dark); + + // select an icon with skin tone + const hand = '👋🏿'; + await tester.tapEmoji(hand); + tester.expectToSeeDocumentIcon(hand); + tester.expectViewHasIcon( + gettingStarted, + ViewLayoutPB.Document, + hand, + ); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/document/document_with_inline_page_test.dart b/frontend/appflowy_flutter/integration_test/document/document_with_inline_page_test.dart index e071f3ee363a..f33ce370ff7e 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_with_inline_page_test.dart +++ b/frontend/appflowy_flutter/integration_test/document/document_with_inline_page_test.dart @@ -15,7 +15,7 @@ void main() { await tester.initializeAppFlowy(); await tester.tapGoButton(); - await insertingInlinePage(tester, ViewLayoutPB.Grid); + await insertInlinePage(tester, ViewLayoutPB.Grid); final mentionBlock = find.byType(MentionPageBlock); expect(mentionBlock, findsOneWidget); @@ -26,7 +26,7 @@ void main() { await tester.initializeAppFlowy(); await tester.tapGoButton(); - await insertingInlinePage(tester, ViewLayoutPB.Board); + await insertInlinePage(tester, ViewLayoutPB.Board); final mentionBlock = find.byType(MentionPageBlock); expect(mentionBlock, findsOneWidget); @@ -37,7 +37,7 @@ void main() { await tester.initializeAppFlowy(); await tester.tapGoButton(); - await insertingInlinePage(tester, ViewLayoutPB.Calendar); + await insertInlinePage(tester, ViewLayoutPB.Calendar); final mentionBlock = find.byType(MentionPageBlock); expect(mentionBlock, findsOneWidget); @@ -48,7 +48,7 @@ void main() { await tester.initializeAppFlowy(); await tester.tapGoButton(); - await insertingInlinePage(tester, ViewLayoutPB.Document); + await insertInlinePage(tester, ViewLayoutPB.Document); final mentionBlock = find.byType(MentionPageBlock); expect(mentionBlock, findsOneWidget); @@ -59,7 +59,7 @@ void main() { await tester.initializeAppFlowy(); await tester.tapGoButton(); - final pageName = await insertingInlinePage(tester, ViewLayoutPB.Document); + final pageName = await insertInlinePage(tester, ViewLayoutPB.Document); // rename const newName = 'RenameToNewPageName'; @@ -78,7 +78,7 @@ void main() { await tester.initializeAppFlowy(); await tester.tapGoButton(); - final pageName = await insertingInlinePage(tester, ViewLayoutPB.Grid); + final pageName = await insertInlinePage(tester, ViewLayoutPB.Grid); // rename await tester.hoverOnPageName( @@ -98,7 +98,7 @@ void main() { } /// Insert a referenced database of [layout] into the document -Future insertingInlinePage( +Future insertInlinePage( WidgetTester tester, ViewLayoutPB layout, ) async { @@ -110,15 +110,19 @@ Future insertingInlinePage( layout: layout, openAfterCreated: false, ); + // create a new document await tester.createNewPageWithName( name: 'insert_a_inline_page_${layout.name}', layout: ViewLayoutPB.Document, ); + // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); + // insert a inline page await tester.editor.showAtMenu(); await tester.editor.tapAtMenuItemWithName(name); + return name; } diff --git a/frontend/appflowy_flutter/integration_test/document/document_with_toggle_list_test.dart b/frontend/appflowy_flutter/integration_test/document/document_with_toggle_list_test.dart index 50b84ed521cc..80bdcbe98303 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_with_toggle_list_test.dart +++ b/frontend/appflowy_flutter/integration_test/document/document_with_toggle_list_test.dart @@ -15,14 +15,23 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('toggle list in document', () { + Finder findToggleListIcon({ + required bool isExpanded, + }) { + final turns = isExpanded ? 0.25 : 0.0; + return find.byWidgetPredicate( + (widget) => widget is AnimatedRotation && widget.turns == turns, + ); + } + void expectToggleListOpened() { - expect(find.byIcon(Icons.arrow_drop_down), findsOneWidget); - expect(find.byIcon(Icons.arrow_right), findsNothing); + expect(findToggleListIcon(isExpanded: true), findsOneWidget); + expect(findToggleListIcon(isExpanded: false), findsNothing); } void expectToggleListClosed() { - expect(find.byIcon(Icons.arrow_drop_down), findsNothing); - expect(find.byIcon(Icons.arrow_right), findsOneWidget); + expect(findToggleListIcon(isExpanded: false), findsOneWidget); + expect(findToggleListIcon(isExpanded: true), findsNothing); } testWidgets('convert > to toggle list, and click the icon to close it', @@ -63,7 +72,7 @@ void main() { expect(find.text(text2, findRichText: true), findsOneWidget); // Click the toggle list icon to close it - final toggleListIcon = find.byIcon(Icons.arrow_drop_down); + final toggleListIcon = find.byIcon(Icons.arrow_right); await tester.tapButton(toggleListIcon); // expect the toggle list to be closed @@ -88,7 +97,7 @@ void main() { await tester.ime.insertText('> $text'); // Click the toggle list icon to close it - final toggleListIcon = find.byIcon(Icons.arrow_drop_down); + final toggleListIcon = find.byIcon(Icons.arrow_right); await tester.tapButton(toggleListIcon); // Press the enter key @@ -164,7 +173,7 @@ void main() { // Press the enter key // Click the toggle list icon to close it - final toggleListIcon = find.byIcon(Icons.arrow_drop_down); + final toggleListIcon = find.byIcon(Icons.arrow_right); await tester.tapButton(toggleListIcon); await tester.editor.updateSelection( diff --git a/frontend/appflowy_flutter/integration_test/document/edit_document_test.dart b/frontend/appflowy_flutter/integration_test/document/edit_document_test.dart index b120b5e33272..280ff2457733 100644 --- a/frontend/appflowy_flutter/integration_test/document/edit_document_test.dart +++ b/frontend/appflowy_flutter/integration_test/document/edit_document_test.dart @@ -113,9 +113,9 @@ const _sample = r''' [] Type followed by bullet or num to create a list. -[x] Click `+ New Page` button at the bottom of your sidebar to add a new page. +[x] Click `New Page` button at the bottom of your sidebar to add a new page. -[] Click `+` next to any page title in the sidebar to quickly add a new subpage, `Document`, `Grid`, or `Kanban Board`. +[] Click the plus sign next to any page title in the sidebar to quickly add a new subpage, `Document`, `Grid`, or `Kanban Board`. --- * bulleted list 1 diff --git a/frontend/appflowy_flutter/integration_test/runner.dart b/frontend/appflowy_flutter/integration_test/runner.dart index 84dac3900787..d34712920ee9 100644 --- a/frontend/appflowy_flutter/integration_test/runner.dart +++ b/frontend/appflowy_flutter/integration_test/runner.dart @@ -23,8 +23,11 @@ import 'sidebar/sidebar_test_runner.dart' as sidebar_test_runner; import 'tabs/tabs_test.dart' as tabs_test; import 'empty_test.dart' as first_test; import 'hotkeys_test.dart' as hotkeys_test; -import 'settings/user_icon_test.dart' as user_icon_test; -import 'settings/user_language_test.dart' as user_language_test; +import 'import_files_test.dart' as import_files_test; +import 'settings/settings_runner.dart' as settings_test_runner; +import 'share_markdown_test.dart' as share_markdown_test; +import 'sidebar/sidebar_test_runner.dart' as sidebar_test_runner; +import 'switch_folder_test.dart' as switch_folder_test; import 'panes/panes_test.dart' as panes_test; import 'panes/panes_overlay_test.dart' as panes_overlay_test; @@ -81,8 +84,7 @@ void main() { appearance_test_runner.main(); // User settings - user_icon_test.main(); - user_language_test.main(); + settings_test_runner.main(); if (isCloudEnabled) { auth_test_runner.main(); diff --git a/frontend/appflowy_flutter/integration_test/settings/notifications_settings_test.dart b/frontend/appflowy_flutter/integration_test/settings/notifications_settings_test.dart new file mode 100644 index 000000000000..ab7458b2501f --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/settings/notifications_settings_test.dart @@ -0,0 +1,41 @@ +import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../util/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('board add row test', () { + testWidgets('Add card from header', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + await tester.openSettings(); + await tester.openSettingsPage(SettingsPage.notifications); + await tester.pumpAndSettle(); + + final switchFinder = find.byType(Switch); + + // Defaults to enabled + Switch switchWidget = tester.widget(switchFinder); + expect(switchWidget.value, true); + + // Disable + await tester.tap(switchFinder); + await tester.pumpAndSettle(); + + switchWidget = tester.widget(switchFinder); + expect(switchWidget.value, false); + + // Enable again + await tester.tap(switchFinder); + await tester.pumpAndSettle(); + + switchWidget = tester.widget(switchFinder); + expect(switchWidget.value, true); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/settings/settings_runner.dart b/frontend/appflowy_flutter/integration_test/settings/settings_runner.dart new file mode 100644 index 000000000000..2b5691569037 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/settings/settings_runner.dart @@ -0,0 +1,13 @@ +import 'package:integration_test/integration_test.dart'; + +import 'notifications_settings_test.dart' as notifications_settings_test; +import 'user_icon_test.dart' as user_icon_test; +import 'user_language_test.dart' as user_language_test; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + notifications_settings_test.main(); + user_icon_test.main(); + user_language_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart index c0842b487b66..4aff4ca836b5 100644 --- a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart +++ b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart @@ -99,7 +99,7 @@ void main() { ); expect( tester.findFavoritePageName(name), - findsNothing, + findsOneWidget, ); }, ); @@ -127,7 +127,7 @@ void main() { expect( find.byWidgetPredicate( (widget) => - widget is ViewItem && + widget is SingleInnerViewItem && widget.view.isFavorite && widget.categoryType == FolderCategoryType.favorite, ), @@ -144,12 +144,7 @@ void main() { ); expect( - find.byWidgetPredicate( - (widget) => - widget is ViewItem && - widget.view.isFavorite && - widget.categoryType == FolderCategoryType.favorite, - ), + tester.findAllFavoritePages(), findsNWidgets(3), ); @@ -163,12 +158,7 @@ void main() { ); expect( - find.byWidgetPredicate( - (widget) => - widget is ViewItem && - widget.view.isFavorite && - widget.categoryType == FolderCategoryType.favorite, - ), + tester.findAllFavoritePages(), findsNothing, ); }, diff --git a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_icon_test.dart b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_icon_test.dart new file mode 100644 index 000000000000..e2681c2e9533 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_icon_test.dart @@ -0,0 +1,76 @@ +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../util/base.dart'; +import '../util/common_operations.dart'; +import '../util/expectation.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const emoji = '😁'; + + group('Icon', () { + testWidgets('Update page icon in sidebar', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + // create document, board, grid and calendar views + for (final value in ViewLayoutPB.values) { + await tester.createNewPageWithName( + name: value.name, + parentName: gettingStarted, + layout: value, + ); + + // update its icon + await tester.updatePageIconInSidebarByName( + name: value.name, + parentName: gettingStarted, + layout: value, + icon: emoji, + ); + + tester.expectViewHasIcon( + value.name, + value, + emoji, + ); + } + }); + + testWidgets('Update page icon in title bar', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + // create document, board, grid and calendar views + for (final value in ViewLayoutPB.values) { + await tester.createNewPageWithName( + name: value.name, + parentName: gettingStarted, + layout: value, + ); + + // update its icon + await tester.updatePageIconInTitleBarByName( + name: value.name, + layout: value, + icon: emoji, + ); + + tester.expectViewHasIcon( + value.name, + value, + emoji, + ); + + tester.expectViewTitleHasIcon( + value.name, + value, + emoji, + ); + } + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test_runner.dart b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test_runner.dart index f4d54a2160c0..bf199036a83e 100644 --- a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test_runner.dart +++ b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test_runner.dart @@ -1,8 +1,9 @@ import 'package:integration_test/integration_test.dart'; -import 'sidebar_test.dart' as sidebar_test; import 'sidebar_expand_test.dart' as sidebar_expanded_test; import 'sidebar_favorites_test.dart' as sidebar_favorite_test; +import 'sidebar_icon_test.dart' as sidebar_icon_test; +import 'sidebar_test.dart' as sidebar_test; void startTesting() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -11,4 +12,5 @@ void startTesting() { sidebar_test.main(); sidebar_expanded_test.main(); sidebar_favorite_test.main(); + sidebar_icon_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/util/base.dart b/frontend/appflowy_flutter/integration_test/util/base.dart index bfe6a8d833f0..b28188629bf1 100644 --- a/frontend/appflowy_flutter/integration_test/util/base.dart +++ b/frontend/appflowy_flutter/integration_test/util/base.dart @@ -1,9 +1,11 @@ import 'dart:async'; import 'dart:io'; +import 'package:appflowy/env/env.dart'; import 'package:appflowy/startup/entry_point.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/presentation/presentation.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -81,9 +83,15 @@ extension AppFlowyTestBase on WidgetTester { } Future waitUntilSignInPageShow() async { - final finder = find.byType(GoButton); - await pumpUntilFound(finder); - expect(finder, findsOneWidget); + if (isCloudEnabled) { + final finder = find.byType(SignInAnonymousButton); + await pumpUntilFound(finder); + expect(finder, findsOneWidget); + } else { + final finder = find.byType(GoButton); + await pumpUntilFound(finder); + expect(finder, findsOneWidget); + } } Future pumpUntilFound( diff --git a/frontend/appflowy_flutter/integration_test/util/common_operations.dart b/frontend/appflowy_flutter/integration_test/util/common_operations.dart index e8d160fec2c9..2e0775ada9ad 100644 --- a/frontend/appflowy_flutter/integration_test/util/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/util/common_operations.dart @@ -4,15 +4,18 @@ import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; import 'package:appflowy/plugins/document/presentation/share/share_button.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/presentation/home/menu/sidebar/sidebar_new_page_button.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart'; +import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -22,13 +25,21 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'emoji.dart'; import 'util.dart'; extension CommonOperations on WidgetTester { /// Tap the GetStart button on the launch page. Future tapGoButton() async { + // local version final goButton = find.byType(GoButton); - await tapButton(goButton); + if (goButton.evaluate().isNotEmpty) { + await tapButton(goButton); + } else { + // cloud version + final anonymousButton = find.byType(SignInAnonymousButton); + await tapButton(anonymousButton); + } if (Platform.isWindows) { await pumpAndSettle(const Duration(milliseconds: 200)); @@ -472,6 +483,47 @@ extension CommonOperations on WidgetTester { }) async { await tapButtonWithFlowySvgData(FlowySvgs.close_s, first); } + + // update the page icon in the sidebar + Future updatePageIconInSidebarByName({ + required String name, + required String parentName, + required ViewLayoutPB layout, + required String icon, + }) async { + final iconButton = find.descendant( + of: findPageName( + name, + layout: layout, + parentName: parentName, + ), + matching: + find.byTooltip(LocaleKeys.document_plugins_cover_changeIcon.tr()), + ); + await tapButton(iconButton); + await tapEmoji(icon); + await pumpAndSettle(); + } + + // update the page icon in the sidebar + Future updatePageIconInTitleBarByName({ + required String name, + required ViewLayoutPB layout, + required String icon, + }) async { + await openPage( + name, + layout: layout, + ); + final title = find.descendant( + of: find.byType(ViewTitleBar), + matching: find.text(name), + ); + await tapButton(title); + await tapButton(find.byType(EmojiPickerButton)); + await tapEmoji(icon); + await pumpAndSettle(); + } } extension ViewLayoutPBTest on ViewLayoutPB { diff --git a/frontend/appflowy_flutter/integration_test/util/database_test_op.dart b/frontend/appflowy_flutter/integration_test/util/database_test_op.dart index 89b94e6a2cbf..98ae23b2a2b6 100644 --- a/frontend/appflowy_flutter/integration_test/util/database_test_op.dart +++ b/frontend/appflowy_flutter/integration_test/util/database_test_op.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database_view/board/presentation/board_page.dart'; +import 'package:appflowy/plugins/database_view/board/presentation/widgets/board_column_header.dart'; import 'package:appflowy/plugins/database_view/calendar/application/calendar_bloc.dart'; import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_day.dart'; import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_event_card.dart'; @@ -35,10 +36,9 @@ import 'package:appflowy/plugins/database_view/grid/presentation/widgets/sort/so import 'package:appflowy/plugins/database_view/grid/presentation/widgets/toolbar/filter_button.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/widgets/toolbar/grid_layout.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/widgets/toolbar/sort_button.dart'; -import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_header.dart'; -import 'package:appflowy/plugins/database_view/tar_bar/tar_bar_add_button.dart'; +import 'package:appflowy/plugins/database_view/tab_bar/tab_bar_header.dart'; +import 'package:appflowy/plugins/database_view/tab_bar/tab_bar_add_button.dart'; import 'package:appflowy/plugins/database_view/widgets/database_layout_ext.dart'; -import 'package:appflowy/plugins/database_view/widgets/setting/setting_property_list.dart'; import 'package:appflowy/plugins/database_view/widgets/row/accessory/cell_accessory.dart'; import 'package:appflowy/plugins/database_view/widgets/row/cells/cells.dart'; import 'package:appflowy/plugins/database_view/widgets/row/cells/checklist_cell/checklist_cell_editor.dart'; @@ -53,13 +53,14 @@ import 'package:appflowy/plugins/database_view/widgets/row/row_detail.dart'; import 'package:appflowy/plugins/database_view/widgets/row/row_document.dart'; import 'package:appflowy/plugins/database_view/widgets/row/row_property.dart'; import 'package:appflowy/plugins/database_view/widgets/setting/setting_button.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart'; +import 'package:appflowy/plugins/database_view/widgets/setting/setting_property_list.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:appflowy_board/appflowy_board.dart'; import 'package:calendar_view/calendar_view.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -618,7 +619,6 @@ extension AppFlowyDatabaseTest on WidgetTester { Future openEmojiPicker() async { await tapButton(find.byType(EmojiPickerButton)); - await tapButton(find.byType(EmojiSelectionMenu)); } Future tapDateCellInRowDetailPage() async { @@ -1392,6 +1392,84 @@ extension AppFlowyDatabaseTest on WidgetTester { await tapButton(findCreateButton); } + void assertNumberOfGroups(int number) { + final groups = find.byType(BoardColumnHeader, skipOffstage: false); + expect(groups, findsNWidgets(number)); + } + + Future scrollBoardToEnd() async { + final scrollable = find + .descendant( + of: find.byType(AppFlowyBoard), + matching: find.byWidgetPredicate( + (widget) => widget is Scrollable && widget.axis == Axis.horizontal, + ), + ) + .first; + await scrollUntilVisible( + find.byType(BoardTrailing), + 300, + scrollable: scrollable, + ); + } + + Future tapNewGroupButton() async { + final button = find.descendant( + of: find.byType(BoardTrailing), + matching: find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg == FlowySvgs.add_s, + ), + ); + expect(button, findsOneWidget); + await tapButton(button); + } + + void assertNewGroupTextField(bool isVisible) { + final textField = find.descendant( + of: find.byType(BoardTrailing), + matching: find.byType(TextField), + ); + if (isVisible) { + expect(textField, findsOneWidget); + } else { + expect(textField, findsNothing); + } + } + + Future enterNewGroupName(String name, {required bool submit}) async { + final textField = find.descendant( + of: find.byType(BoardTrailing), + matching: find.byType(TextField), + ); + await enterText(textField, name); + await pumpAndSettle(); + if (submit) { + await testTextInput.receiveAction(TextInputAction.done); + await pumpAndSettle(); + } + } + + Future clearNewGroupTextField() async { + final textField = find.descendant( + of: find.byType(BoardTrailing), + matching: find.byType(TextField), + ); + await tapButton( + find.descendant( + of: textField, + matching: find.byWidgetPredicate( + (widget) => + widget is FlowySvg && widget.svg == FlowySvgs.close_filled_m, + ), + ), + ); + final textFieldWidget = widget(textField); + assert( + textFieldWidget.controller != null && + textFieldWidget.controller!.text.isEmpty, + ); + } + Future tapTabBarLinkedViewByViewName(String name) async { final viewButton = findTabBarLinkViewByViewName(name); await tapButton(viewButton); diff --git a/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart b/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart index 5b3e8c07775a..73dd494922ae 100644 --- a/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart +++ b/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart @@ -1,17 +1,20 @@ import 'dart:ui'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart'; +import 'package:appflowy/plugins/base/icon/icon_picker.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_popover.dart'; import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; 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 'util.dart'; @@ -54,7 +57,27 @@ class EditorOperations { await tester.tapButtonWithName( LocaleKeys.document_plugins_cover_addIcon.tr(), ); - expect(find.byType(EmojiPopover), findsOneWidget); + expect(find.byType(FlowyEmojiPicker), findsOneWidget); + } + + Future tapGettingStartedIcon() async { + await tester.tapButton( + find.descendant( + of: find.byType(DocumentHeaderNodeWidget), + matching: find.findTextInFlowyText('⭐️'), + ), + ); + } + + /// Taps on the 'Skin tone' button + /// + /// Must call [tapAddIconButton] first. + Future changeEmojiSkinTone(EmojiSkinTone skinTone) async { + await tester.tapButton( + find.byTooltip(LocaleKeys.emoji_selectSkinTone.tr()), + ); + final skinToneButton = find.byKey(emojiSkinToneKey(skinTone.icon)); + await tester.tapButton(skinToneButton); } /// Taps the 'Remove Icon' button in the cover toolbar and the icon popover @@ -62,7 +85,10 @@ class EditorOperations { Finder button = find.text(LocaleKeys.document_plugins_cover_removeIcon.tr()); if (isInPicker) { - button = find.descendant(of: find.byType(EmojiPopover), matching: button); + button = find.descendant( + of: find.byType(FlowyIconPicker), + matching: button, + ); } await tester.tapButton(button); @@ -143,7 +169,7 @@ class EditorOperations { await tester.ime.insertCharacter('/'); } - /// trigger the slash command (selection menu) + /// trigger the mention (@) command Future showAtMenu() async { await tester.ime.insertCharacter('@'); } diff --git a/frontend/appflowy_flutter/integration_test/util/emoji.dart b/frontend/appflowy_flutter/integration_test/util/emoji.dart index 616f3da6eccc..d439a9b3f7b6 100644 --- a/frontend/appflowy_flutter/integration_test/util/emoji.dart +++ b/frontend/appflowy_flutter/integration_test/util/emoji.dart @@ -1,17 +1,14 @@ -import 'package:flutter/material.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'package:flutter_test/flutter_test.dart'; import 'base.dart'; extension EmojiTestExtension on WidgetTester { - /// Must call [openEmojiPicker] first - Future switchToEmojiList() async { - final icon = find.byIcon(Icons.tag_faces); - await tapButton(icon); - } - Future tapEmoji(String emoji) async { - final emojiWidget = find.text(emoji); + final emojiWidget = find.descendant( + of: find.byType(EmojiPicker), + matching: find.text(emoji), + ); await tapButton(emojiWidget); } } diff --git a/frontend/appflowy_flutter/integration_test/util/expectation.dart b/frontend/appflowy_flutter/integration_test/util/expectation.dart index 56bd2c6804d7..b808ec7b841a 100644 --- a/frontend/appflowy_flutter/integration_test/util/expectation.dart +++ b/frontend/appflowy_flutter/integration_test/util/expectation.dart @@ -5,13 +5,14 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emo import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_test/flutter_test.dart'; // const String readme = 'Read me'; -const String gettingStarted = '⭐️ Getting started'; +const String gettingStarted = 'Getting started'; extension Expectation on WidgetTester { /// Expect to see the home page and with a default read me page. @@ -108,6 +109,13 @@ extension Expectation on WidgetTester { expect(iconWidget, findsOneWidget); } + void expectDocumentIconNotNull() { + final iconWidget = find.byWidgetPredicate( + (widget) => widget is EmojiIconWidget && widget.emoji.isNotEmpty, + ); + expect(iconWidget, findsOneWidget); + } + void expectToSeeDocumentCover(CoverType type) { final findCover = find.byWidgetPredicate( (widget) => widget is DocumentCover && widget.coverType == type, @@ -157,7 +165,7 @@ extension Expectation on WidgetTester { }) { return find.byWidgetPredicate( (widget) => - widget is ViewItem && + widget is SingleInnerViewItem && widget.view.isFavorite && widget.categoryType == FolderCategoryType.favorite && widget.view.name == name && @@ -166,6 +174,15 @@ extension Expectation on WidgetTester { ); } + Finder findAllFavoritePages() { + return find.byWidgetPredicate( + (widget) => + widget is SingleInnerViewItem && + widget.view.isFavorite && + widget.categoryType == FolderCategoryType.favorite, + ); + } + Finder findPageName( String name, { ViewLayoutPB layout = ViewLayoutPB.Document, @@ -193,4 +210,24 @@ extension Expectation on WidgetTester { matching: findPageName(name, layout: layout), ); } + + void expectViewHasIcon(String name, ViewLayoutPB layout, String emoji) { + final pageName = findPageName( + name, + layout: layout, + ); + final icon = find.descendant( + of: pageName, + matching: find.text(emoji), + ); + expect(icon, findsOneWidget); + } + + void expectViewTitleHasIcon(String name, ViewLayoutPB layout, String emoji) { + final icon = find.descendant( + of: find.byType(ViewTitleBar), + matching: find.text(emoji), + ); + expect(icon, findsOneWidget); + } } diff --git a/frontend/appflowy_flutter/integration_test/util/mock/mock_openai_repository.dart b/frontend/appflowy_flutter/integration_test/util/mock/mock_openai_repository.dart index 66d63b248b21..2941bcb49b37 100644 --- a/frontend/appflowy_flutter/integration_test/util/mock/mock_openai_repository.dart +++ b/frontend/appflowy_flutter/integration_test/util/mock/mock_openai_repository.dart @@ -15,7 +15,7 @@ class MyMockClient extends Mock implements http.Client { if (requestType == 'POST' && requestUri == OpenAIRequestType.textCompletion.uri) { final responseHeaders = { - 'content-type': 'text/event-stream' + 'content-type': 'text/event-stream', }; final responseBody = Stream.fromIterable([ utf8.encode( diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock index 47e63e5fe9f3..b358068db882 100644 --- a/frontend/appflowy_flutter/ios/Podfile.lock +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -48,6 +48,13 @@ PODS: - fluttertoast (0.0.2): - Flutter - Toast + - FMDB (2.7.5): + - FMDB/standard (= 2.7.5) + - FMDB/standard (2.7.5) + - image_gallery_saver (2.0.2): + - Flutter + - image_picker_ios (0.0.1): + - Flutter - integration_test (0.0.1): - Flutter - irondash_engine_context (0.0.1): @@ -68,6 +75,9 @@ PODS: - FlutterMacOS - sign_in_with_apple (0.0.1): - Flutter + - sqflite (0.0.3): + - Flutter + - FMDB (>= 2.7.5) - super_native_extensions (0.0.1): - Flutter - SwiftyGif (5.4.3) @@ -86,6 +96,8 @@ DEPENDENCIES: - flowy_infra_ui (from `.symlinks/plugins/flowy_infra_ui/ios`) - Flutter (from `Flutter`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) + - image_gallery_saver (from `.symlinks/plugins/image_gallery_saver/ios`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) @@ -93,6 +105,7 @@ DEPENDENCIES: - rich_clipboard_ios (from `.symlinks/plugins/rich_clipboard_ios/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) + - sqflite (from `.symlinks/plugins/sqflite/ios`) - super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`) @@ -101,6 +114,7 @@ SPEC REPOS: trunk: - DKImagePickerController - DKPhotoGallery + - FMDB - ReachabilitySwift - SDWebImage - SwiftyGif @@ -123,6 +137,10 @@ EXTERNAL SOURCES: :path: Flutter fluttertoast: :path: ".symlinks/plugins/fluttertoast/ios" + image_gallery_saver: + :path: ".symlinks/plugins/image_gallery_saver/ios" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" integration_test: :path: ".symlinks/plugins/integration_test/ios" irondash_engine_context: @@ -137,6 +155,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sign_in_with_apple: :path: ".symlinks/plugins/sign_in_with_apple/ios" + sqflite: + :path: ".symlinks/plugins/sqflite/ios" super_native_extensions: :path: ".symlinks/plugins/super_native_extensions/ios" url_launcher_ios: @@ -151,10 +171,13 @@ SPEC CHECKSUMS: device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: ce3938a0df3cc1ef404671531facef740d03f920 + file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c + FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a + image_gallery_saver: cb43cc43141711190510e92c460eb1655cd343cb + image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 integration_test: 13825b8a9334a850581300559b8839134b124670 irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9 package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 @@ -164,6 +187,7 @@ SPEC CHECKSUMS: SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84 shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c sign_in_with_apple: f3bf75217ea4c2c8b91823f225d70230119b8440 + sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7 SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 diff --git a/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj index cd5e7de15d61..50fd566bf0b0 100644 --- a/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj @@ -143,7 +143,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1430; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { diff --git a/frontend/appflowy_flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/appflowy_flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 3db53b6e1fb7..b52b2e698b7e 100644 --- a/frontend/appflowy_flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/frontend/appflowy_flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ - - NSPhotoLibraryUsageDescription - This app requires access to the photo library. - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleLocalizations - - en - - CFBundleName - AppFlowy - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLName - - CFBundleURLSchemes - - appflowy-flutter - - - - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - - + + NSCameraUsageDescription + AppFlowy requires access to the camera. + NSPhotoLibraryUsageDescription + AppFlowy requires access to the photo library. + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + + FLTEnableImpeller + + CFBundleName + AppFlowy + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLName + + CFBundleURLSchemes + + appflowy-flutter + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/env_serde.dart b/frontend/appflowy_flutter/lib/env/backend_env.dart similarity index 85% rename from frontend/appflowy_flutter/packages/appflowy_backend/lib/env_serde.dart rename to frontend/appflowy_flutter/lib/env/backend_env.dart index 63000278d6db..b9355e66ea86 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/env_serde.dart +++ b/frontend/appflowy_flutter/lib/env/backend_env.dart @@ -1,10 +1,7 @@ -import 'package:json_annotation/json_annotation.dart'; +// ignore_for_file: non_constant_identifier_names -// Run `dart run build_runner build` to generate the json serialization If the -// file `env_serde.g.dart` is existed, delete it first. -// -// the file `env_serde.g.dart` will be generated in the same directory. -part 'env_serde.g.dart'; +import 'package:json_annotation/json_annotation.dart'; +part 'backend_env.g.dart'; @JsonSerializable() class AppFlowyEnv { diff --git a/frontend/appflowy_flutter/lib/env/env.dart b/frontend/appflowy_flutter/lib/env/env.dart index 0c3dc86e2773..3cb2493926ea 100644 --- a/frontend/appflowy_flutter/lib/env/env.dart +++ b/frontend/appflowy_flutter/lib/env/env.dart @@ -101,7 +101,10 @@ CloudType currentCloudType() { final value = Env.cloudType; if (value == 1) { if (Env.supabaseUrl.isEmpty || Env.supabaseAnonKey.isEmpty) { - Log.error("Supabase is not configured"); + Log.error( + "Supabase is not configured correctly. The values are: " + "url: ${Env.supabaseUrl}, anonKey: ${Env.supabaseAnonKey}", + ); return CloudType.unknown; } else { return CloudType.supabase; @@ -109,8 +112,13 @@ CloudType currentCloudType() { } if (value == 2) { - if (Env.afCloudBaseUrl.isEmpty || Env.afCloudWSBaseUrl.isEmpty) { - Log.error("AppFlowy cloud is not configured"); + if (Env.afCloudBaseUrl.isEmpty || + Env.afCloudWSBaseUrl.isEmpty || + Env.afCloudGoTrueUrl.isEmpty) { + Log.error( + "AppFlowy cloud is not configured correctly. The values are: " + "baseUrl: ${Env.afCloudBaseUrl}, wsBaseUrl: ${Env.afCloudWSBaseUrl}, gotrueUrl: ${Env.afCloudGoTrueUrl}", + ); return CloudType.unknown; } else { return CloudType.appflowyCloud; diff --git a/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart b/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart index c32463c7d8b6..2a8a4ef75f59 100644 --- a/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart +++ b/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart @@ -2,12 +2,22 @@ import 'package:appflowy/mobile/presentation/database/mobile_board_screen.dart'; import 'package:appflowy/mobile/presentation/database/mobile_calendar_screen.dart'; import 'package:appflowy/mobile/presentation/database/mobile_grid_screen.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +class MobileRouterRecord { + PropertyValueNotifier lastPushedRouter = + PropertyValueNotifier(''); +} + extension MobileRouter on BuildContext { Future pushView(ViewPB view) async { + await FolderEventSetLatestView(ViewIdPB(value: view.id)).send(); + getIt().lastPushedRouter.value = view.routeName; push( Uri( path: view.routeName, diff --git a/frontend/appflowy_flutter/lib/mobile/application/mobile_theme_data.dart b/frontend/appflowy_flutter/lib/mobile/application/mobile_theme_data.dart index 55e827f10316..7d24ebc0a2fa 100644 --- a/frontend/appflowy_flutter/lib/mobile/application/mobile_theme_data.dart +++ b/frontend/appflowy_flutter/lib/mobile/application/mobile_theme_data.dart @@ -34,6 +34,7 @@ ThemeData getMobileThemeData( //Snack bar surface: Colors.white, onSurface: _onSurfaceColor, // text/body color + surfaceVariant: const Color.fromARGB(255, 216, 216, 216), ) : ColorScheme( brightness: brightness, @@ -64,7 +65,6 @@ ThemeData getMobileThemeData( appBarTheme: AppBarTheme( foregroundColor: mobileColorTheme.onBackground, backgroundColor: mobileColorTheme.background, - elevation: 80, centerTitle: false, titleTextStyle: TextStyle( color: mobileColorTheme.onBackground, @@ -116,7 +116,7 @@ ThemeData getMobileThemeData( foregroundColor: MaterialStateProperty.all( mobileColorTheme.onBackground, ), - backgroundColor: MaterialStateProperty.all(Colors.white), + backgroundColor: MaterialStateProperty.all(mobileColorTheme.background), shape: MaterialStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(6), @@ -129,7 +129,7 @@ ThemeData getMobileThemeData( ), ), padding: MaterialStateProperty.all( - const EdgeInsets.symmetric(horizontal: 16), + const EdgeInsets.symmetric(horizontal: 8, vertical: 12), ), // splash color overlayColor: MaterialStateProperty.all( @@ -224,6 +224,7 @@ ThemeData getMobileThemeData( ), ), colorScheme: mobileColorTheme, + indicatorColor: Colors.blue, extensions: [ AFThemeExtension( warning: theme.yellow, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart index e63008f25fb6..77a975744fee 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart @@ -1,8 +1,8 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart'; -import 'package:appflowy/mobile/presentation/error/error_page.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart'; +import 'package:appflowy/plugins/document/document_page.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; @@ -56,8 +56,11 @@ class _MobileViewPageState extends State { child: CircularProgressIndicator(), ); } else if (!state.hasData) { - body = MobileErrorPage( - message: LocaleKeys.error_loadingViewError.tr(), + body = FlowyMobileStateContainer.error( + emoji: '😔', + title: LocaleKeys.error_weAreSorry.tr(), + description: LocaleKeys.error_loadingViewError.tr(), + errorMsg: state.error.toString(), ); } else { body = state.data!.fold((view) { @@ -65,8 +68,11 @@ class _MobileViewPageState extends State { actions.add(_buildAppBarMoreButton(view)); return view.plugin().widgetBuilder.buildWidget(shrinkWrap: false); }, (error) { - return MobileErrorPage( - message: error.toString(), + return FlowyMobileStateContainer.error( + emoji: '😔', + title: LocaleKeys.error_weAreSorry.tr(), + description: LocaleKeys.error_loadingViewError.tr(), + errorMsg: error.toString(), ); }); } @@ -106,12 +112,26 @@ class _MobileViewPageState extends State { } Widget _buildApp(ViewPB? view, List actions, Widget child) { + final icon = view?.icon.value; return Scaffold( appBar: AppBar( titleSpacing: 0, - title: FlowyText.semibold( - view?.name ?? widget.title ?? '', - fontSize: 14.0, + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) + FlowyText( + '$icon ', + fontSize: 22.0, + ), + Expanded( + child: FlowyText.regular( + view?.name ?? widget.title ?? '', + fontSize: 14.0, + overflow: TextOverflow.ellipsis, + ), + ), + ], ), leading: AppBarBackButton( onTap: () => context.pop(), @@ -163,12 +183,21 @@ class _MobileViewPageState extends State { context.read().add(FavoriteEvent.toggle(view)); break; case MobileViewBottomSheetBodyAction.undo: + context.dispatchNotification( + const EditorNotification(type: EditorNotificationType.redo), + ); + context.pop(); + break; case MobileViewBottomSheetBodyAction.redo: + context.pop(); + context.dispatchNotification(EditorNotification.redo()); + break; case MobileViewBottomSheetBodyAction.helpCenter: // unimplemented context.pop(); break; case MobileViewBottomSheetBodyAction.rename: + // no need to implement, rename is handled by the onRename callback. throw UnimplementedError(); } }, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet.dart index c1f43af889a0..a128f5434778 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet.dart @@ -1,146 +1,9 @@ -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_drag_handler.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_rename_widget.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_view_item_header.dart'; -import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; -import 'package:appflowy/workspace/application/view/view_bloc.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart' hide WidgetBuilder; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; - -Future showMobileBottomSheet({ - required BuildContext context, - required WidgetBuilder builder, -}) async { - showModalBottomSheet( - context: context, - isScrollControlled: true, - enableDrag: true, - useSafeArea: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(8.0), - topRight: Radius.circular(8.0), - ), - ), - builder: builder, - ); -} - -enum MobileBottomSheetType { - view, - rename, -} - -class MobileViewItemBottomSheet extends StatefulWidget { - const MobileViewItemBottomSheet({ - super.key, - required this.view, - this.defaultType = MobileBottomSheetType.view, - }); - - final ViewPB view; - final MobileBottomSheetType defaultType; - - @override - State createState() => - _MobileViewItemBottomSheetState(); -} - -class _MobileViewItemBottomSheetState extends State { - MobileBottomSheetType type = MobileBottomSheetType.view; - - @override - initState() { - super.initState(); - - type = widget.defaultType; - } - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // drag handler - const MobileBottomSheetDragHandler(), - - // header - _buildHeader(), - const VSpace(8.0), - const Divider(), - - // body - _buildBody(), - const VSpace(12.0), - ], - ); - } - - Widget _buildHeader() { - switch (type) { - case MobileBottomSheetType.view: - case MobileBottomSheetType.rename: - // header - return MobileViewItemBottomSheetHeader( - showBackButton: type != MobileBottomSheetType.view, - view: widget.view, - onBack: () { - setState(() { - type = MobileBottomSheetType.view; - }); - }, - ); - } - } - - Widget _buildBody() { - switch (type) { - case MobileBottomSheetType.view: - return MobileViewItemBottomSheetBody( - isFavorite: widget.view.isFavorite, - onAction: (action) { - switch (action) { - case MobileViewItemBottomSheetBodyAction.rename: - setState(() { - type = MobileBottomSheetType.rename; - }); - break; - case MobileViewItemBottomSheetBodyAction.duplicate: - context.pop(); - context.read().add(const ViewEvent.duplicate()); - break; - case MobileViewItemBottomSheetBodyAction.share: - // unimplemented - context.pop(); - break; - case MobileViewItemBottomSheetBodyAction.delete: - context.pop(); - context.read().add(const ViewEvent.delete()); - - break; - case MobileViewItemBottomSheetBodyAction.addToFavorites: - case MobileViewItemBottomSheetBodyAction.removeFromFavorites: - context.pop(); - context - .read() - .add(FavoriteEvent.toggle(widget.view)); - break; - } - }, - ); - case MobileBottomSheetType.rename: - return MobileBottomSheetRenameWidget( - name: widget.view.name, - onRename: (name) { - if (name != widget.view.name) { - context.read().add(ViewEvent.rename(name)); - } - context.pop(); - }, - ); - } - } -} +export 'bottom_sheet_action_widget.dart'; +export 'bottom_sheet_add_new_page.dart'; +export 'bottom_sheet_drag_handler.dart'; +export 'bottom_sheet_rename_widget.dart'; +export 'bottom_sheet_view_item_body.dart'; +export 'bottom_sheet_view_item_header.dart'; +export 'bottom_sheet_view_page.dart'; +export 'default_mobile_action_pane.dart'; +export 'show_mobile_bottom_sheet.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_action_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_action_widget.dart index 34c243d92ef0..b4aa9deee101 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_action_widget.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_action_widget.dart @@ -1,8 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/mobile/presentation/base/box_container.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; class BottomSheetActionWidget extends StatelessWidget { const BottomSheetActionWidget({ @@ -10,42 +7,31 @@ class BottomSheetActionWidget extends StatelessWidget { required this.svg, required this.text, required this.onTap, + this.iconColor, }); final FlowySvgData svg; final String text; final VoidCallback onTap; + final Color? iconColor; @override Widget build(BuildContext context) { - return FlowyBoxContainer( - child: InkWell( - onTap: () { - HapticFeedback.mediumImpact(); - onTap(); - }, - enableFeedback: true, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 10.0, - horizontal: 12.0, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FlowySvg( - svg, - size: const Size.square(24.0), - blendMode: BlendMode.dst, - ), - const HSpace(6.0), - FlowyText(text), - const Spacer(), - ], - ), - ), + final iconColor = + this.iconColor ?? Theme.of(context).colorScheme.onBackground; + + return OutlinedButton.icon( + icon: FlowySvg( + svg, + size: const Size.square(22.0), + color: iconColor, ), + label: Text(text), + style: Theme.of(context) + .outlinedButtonTheme + .style + ?.copyWith(alignment: Alignment.centerLeft), + onPressed: onTap, ); } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_add_new_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_add_new_page.dart index 4fec21177ed0..a89cf44f7b08 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_add_new_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_add_new_page.dart @@ -1,8 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_action_widget.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_drag_handler.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_view_item_header.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -21,46 +19,9 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget { @override Widget build(BuildContext context) { return Column( - mainAxisSize: MainAxisSize.min, children: [ - // drag handler - const MobileBottomSheetDragHandler(), - - // header - MobileViewItemBottomSheetHeader( - showBackButton: false, - view: view, - onBack: () {}, - ), - const VSpace(8.0), - const Divider(), - - // body - _AddNewPageBody( - onAction: onAction, - ), - const VSpace(24.0), - ], - ); - } -} - -class _AddNewPageBody extends StatelessWidget { - const _AddNewPageBody({ - required this.onAction, - }); - - final void Function(ViewLayoutPB layout) onAction; - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - // rename, duplicate + // new document, new grid Row( - mainAxisSize: MainAxisSize.min, children: [ Expanded( child: BottomSheetActionWidget( @@ -69,6 +30,7 @@ class _AddNewPageBody extends StatelessWidget { onTap: () => onAction(ViewLayoutPB.Document), ), ), + const HSpace(8), Expanded( child: BottomSheetActionWidget( svg: FlowySvgs.grid_s, @@ -78,10 +40,10 @@ class _AddNewPageBody extends StatelessWidget { ), ], ), + const VSpace(8), - // share, delete + // new board, new calendar Row( - mainAxisSize: MainAxisSize.min, children: [ Expanded( child: BottomSheetActionWidget( @@ -90,6 +52,7 @@ class _AddNewPageBody extends StatelessWidget { onTap: () => onAction(ViewLayoutPB.Board), ), ), + const HSpace(8), Expanded( child: BottomSheetActionWidget( svg: FlowySvgs.date_s, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_block_action_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_block_action_widget.dart new file mode 100644 index 000000000000..044e484fe9f4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_block_action_widget.dart @@ -0,0 +1,78 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_action_widget.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +enum BlockActionBottomSheetType { + delete, + duplicate, + insertAbove, + insertBelow, +} + +// Only works on mobile. +class BlockActionBottomSheet extends StatelessWidget { + const BlockActionBottomSheet({ + super.key, + required this.onAction, + this.extendActionWidgets = const [], + }); + + final void Function(BlockActionBottomSheetType layout) onAction; + final List extendActionWidgets; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // insert above, insert below + Row( + children: [ + Expanded( + child: BottomSheetActionWidget( + svg: FlowySvgs.arrow_up_s, + text: LocaleKeys.button_insertAbove.tr(), + onTap: () => onAction(BlockActionBottomSheetType.insertAbove), + ), + ), + const HSpace(8), + Expanded( + child: BottomSheetActionWidget( + svg: FlowySvgs.arrow_down_s, + text: LocaleKeys.button_insertBelow.tr(), + onTap: () => onAction(BlockActionBottomSheetType.insertBelow), + ), + ), + ], + ), + const VSpace(8), + + // duplicate, delete + Row( + children: [ + Expanded( + child: BottomSheetActionWidget( + svg: FlowySvgs.m_duplicate_m, + text: LocaleKeys.button_duplicate.tr(), + onTap: () => onAction(BlockActionBottomSheetType.duplicate), + ), + ), + const HSpace(8), + Expanded( + child: BottomSheetActionWidget( + svg: FlowySvgs.m_delete_m, + text: LocaleKeys.button_delete.tr(), + onTap: () => onAction(BlockActionBottomSheetType.delete), + ), + ), + ], + ), + const VSpace(8), + + ...extendActionWidgets, + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_drag_handler.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_drag_handler.dart index 9a8d6d2253a5..4e9fcd3d7e3e 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_drag_handler.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_drag_handler.dart @@ -12,7 +12,7 @@ class MobileBottomSheetDragHandler extends StatelessWidget { height: 4, decoration: BoxDecoration( borderRadius: BorderRadius.circular(2.0), - color: Theme.of(context).colorScheme.onSecondary, + color: Theme.of(context).hintColor, ), ), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart index 6c4cf8924dcc..5a4806796c99 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart @@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_action_widget.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; enum MobileViewItemBottomSheetBodyAction { @@ -26,12 +27,11 @@ class MobileViewItemBottomSheetBody extends StatelessWidget { @override Widget build(BuildContext context) { return Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // rename, duplicate Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: MainAxisSize.max, children: [ Expanded( child: BottomSheetActionWidget( @@ -42,6 +42,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget { ), ), ), + const HSpace(8), Expanded( child: BottomSheetActionWidget( svg: FlowySvgs.m_duplicate_m, @@ -53,10 +54,11 @@ class MobileViewItemBottomSheetBody extends StatelessWidget { ), ], ), + const VSpace(8), // share, delete Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: MainAxisSize.max, children: [ Expanded( child: BottomSheetActionWidget( @@ -67,6 +69,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget { ), ), ), + const HSpace(8), Expanded( child: BottomSheetActionWidget( svg: FlowySvgs.m_delete_m, @@ -78,13 +81,15 @@ class MobileViewItemBottomSheetBody extends StatelessWidget { ), ], ), + const VSpace(8), - // remove from favorites - + // remove from favorites/add to favorites BottomSheetActionWidget( svg: isFavorite ? FlowySvgs.m_favorite_selected_lg : FlowySvgs.m_favorite_unselected_lg, + //TODO(yijing): switch to theme color + iconColor: isFavorite ? Colors.yellow : null, text: isFavorite ? LocaleKeys.button_removeFromFavorites.tr() : LocaleKeys.button_addToFavorites.tr(), @@ -93,7 +98,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget { ? MobileViewItemBottomSheetBodyAction.removeFromFavorites : MobileViewItemBottomSheetBodyAction.addToFavorites, ), - ) + ), ], ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_header.dart index 1f186af7ae39..4643247b214a 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_header.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_header.dart @@ -1,6 +1,6 @@ import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; class MobileViewItemBottomSheetHeader extends StatelessWidget { const MobileViewItemBottomSheetHeader({ @@ -16,8 +16,8 @@ class MobileViewItemBottomSheetHeader extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // back button, showBackButton @@ -31,14 +31,23 @@ class MobileViewItemBottomSheetHeader extends StatelessWidget { ), ), ) - : const HSpace(40.0), + : const SizedBox.shrink(), // title - FlowyText.regular( - view.name, - fontSize: 16.0, + Expanded( + child: Text( + view.name, + style: theme.textTheme.labelSmall, + ), + ), + IconButton( + icon: Icon( + Icons.close, + color: theme.hintColor, + ), + onPressed: () { + context.pop(); + }, ), - // placeholder, ensure the title is centered - const HSpace(40.0), ], ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart index 69cbc1bc2c72..d1dd3527c32e 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart @@ -1,10 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_action_widget.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_drag_handler.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_rename_widget.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_view_item_header.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -47,21 +43,18 @@ class _ViewPageBottomSheetState extends State { @override Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // drag handler - const MobileBottomSheetDragHandler(), - - // header - _buildHeader(), - const VSpace(8.0), - const Divider(), - - // body - _buildBody(), - const VSpace(24.0), - ], + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // header + _buildHeader(), + const VSpace(16), + // body + _buildBody(), + ], + ), ); } @@ -125,37 +118,38 @@ class MobileViewBottomSheetBody extends StatelessWidget { Widget build(BuildContext context) { final isFavorite = view.isFavorite; return Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // undo, redo - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: BottomSheetActionWidget( - svg: FlowySvgs.m_undo_m, - text: LocaleKeys.toolbar_undo.tr(), - onTap: () => onAction( - MobileViewBottomSheetBodyAction.undo, - ), - ), - ), - Expanded( - child: BottomSheetActionWidget( - svg: FlowySvgs.m_redo_m, - text: LocaleKeys.toolbar_redo.tr(), - onTap: () => onAction( - MobileViewBottomSheetBodyAction.redo, - ), - ), - ), - ], - ), + // Row( + // mainAxisSize: MainAxisSize.max, + // children: [ + // Expanded( + // child: BottomSheetActionWidget( + // svg: FlowySvgs.m_undo_m, + // text: LocaleKeys.toolbar_undo.tr(), + // onTap: () => onAction( + // MobileViewBottomSheetBodyAction.undo, + // ), + // ), + // ), + // const HSpace(8), + // Expanded( + // child: BottomSheetActionWidget( + // svg: FlowySvgs.m_redo_m, + // text: LocaleKeys.toolbar_redo.tr(), + // onTap: () => onAction( + // MobileViewBottomSheetBodyAction.redo, + // ), + // ), + // ), + // ], + // ), + // const VSpace(8), // rename, duplicate Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: MainAxisSize.max, children: [ Expanded( child: BottomSheetActionWidget( @@ -166,6 +160,7 @@ class MobileViewBottomSheetBody extends StatelessWidget { ), ), ), + const HSpace(8), Expanded( child: BottomSheetActionWidget( svg: FlowySvgs.m_duplicate_m, @@ -177,10 +172,11 @@ class MobileViewBottomSheetBody extends StatelessWidget { ), ], ), + const VSpace(8), // share, delete Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: MainAxisSize.max, children: [ Expanded( child: BottomSheetActionWidget( @@ -191,6 +187,7 @@ class MobileViewBottomSheetBody extends StatelessWidget { ), ), ), + const HSpace(8), Expanded( child: BottomSheetActionWidget( svg: FlowySvgs.m_delete_m, @@ -202,12 +199,15 @@ class MobileViewBottomSheetBody extends StatelessWidget { ), ], ), + const VSpace(8), // favorites BottomSheetActionWidget( svg: isFavorite ? FlowySvgs.m_favorite_selected_lg : FlowySvgs.m_favorite_unselected_lg, + //TODO(yijing): switch to theme color + iconColor: isFavorite ? Colors.yellow : null, text: isFavorite ? LocaleKeys.button_removeFromFavorites.tr() : LocaleKeys.button_addToFavorites.tr(), @@ -217,6 +217,7 @@ class MobileViewBottomSheetBody extends StatelessWidget { : MobileViewBottomSheetBodyAction.addToFavorites, ), ), + const VSpace(8), // help center BottomSheetActionWidget( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart index 34ec310da50f..fd41ca52bd07 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart @@ -1,5 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/page_item/mobile_slide_action_button.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart similarity index 84% rename from frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart rename to frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart index d6d252fe7c0f..c8116ff380e9 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart @@ -1,7 +1,4 @@ -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_drag_handler.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_rename_widget.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_view_item_header.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart'; @@ -10,6 +7,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +Future showMobileBottomSheet({ + required BuildContext context, + required WidgetBuilder builder, +}) async { + showModalBottomSheet( + context: context, + isScrollControlled: true, + enableDrag: true, + useSafeArea: true, + builder: builder, + ); +} + enum MobileBottomSheetType { view, rename, @@ -42,21 +52,18 @@ class _MobileViewItemBottomSheetState extends State { @override Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // drag handler - const MobileBottomSheetDragHandler(), - - // header - _buildHeader(), - const VSpace(8.0), - const Divider(), - - // body - _buildBody(), - const VSpace(24.0), - ], + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // header + _buildHeader(), + const VSpace(16), + // body + _buildBody(), + ], + ), ); } @@ -90,23 +97,24 @@ class _MobileViewItemBottomSheetState extends State { }); break; case MobileViewItemBottomSheetBodyAction.duplicate: - context.read().add(const ViewEvent.duplicate()); context.pop(); + context.read().add(const ViewEvent.duplicate()); break; case MobileViewItemBottomSheetBodyAction.share: // unimplemented context.pop(); break; case MobileViewItemBottomSheetBodyAction.delete: - context.read().add(const ViewEvent.delete()); context.pop(); + context.read().add(const ViewEvent.delete()); + break; case MobileViewItemBottomSheetBodyAction.addToFavorites: case MobileViewItemBottomSheetBodyAction.removeFromFavorites: + context.pop(); context .read() .add(FavoriteEvent.toggle(widget.view)); - context.pop(); break; } }, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/details_placeholder_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/details_placeholder_page.dart index 84a0f20086dd..cef64bc19cb0 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/details_placeholder_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/details_placeholder_page.dart @@ -91,7 +91,7 @@ class DetailsPlaceholderScreenState extends State { style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), ), ), - ] + ], ], ), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/error/error_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/error/error_page.dart deleted file mode 100644 index 09ef2cf62709..000000000000 --- a/frontend/appflowy_flutter/lib/mobile/presentation/error/error_page.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; - -class MobileErrorPage extends StatelessWidget { - const MobileErrorPage({ - super.key, - this.header, - this.title, - required this.message, - }); - - final Widget? header; - final String? title; - final String message; - - @override - Widget build(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - header != null - ? header! - : const FlowyText.semibold( - '😔', - fontSize: 50, - ), - const VSpace(14.0), - FlowyText.semibold( - title ?? LocaleKeys.error_weAreSorry.tr(), - fontSize: 32, - textAlign: TextAlign.center, - ), - const VSpace(4.0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: FlowyText.regular( - message, - fontSize: 16, - maxLines: 100, - color: Colors.grey, // FIXME: use theme color - textAlign: TextAlign.center, - ), - ), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.dart b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.dart index dd11796c82ac..07a36f2a47a5 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.dart @@ -1,7 +1,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; -import 'package:appflowy/mobile/presentation/error/error_page.dart'; import 'package:appflowy/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart'; @@ -29,12 +29,12 @@ class MobileFavoritePageFolder extends StatelessWidget { BlocProvider( create: (_) => MenuBloc( user: userProfile, - workspace: workspaceSetting.workspace, + workspaceId: workspaceSetting.workspaceId, )..add(const MenuEvent.initial()), ), BlocProvider( create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), - ) + ), ], child: MultiBlocListener( listeners: [ @@ -49,27 +49,27 @@ class MobileFavoritePageFolder extends StatelessWidget { builder: (context) { final favoriteState = context.watch().state; if (favoriteState.views.isEmpty) { - return MobileErrorPage( - header: const FlowyText.semibold( - '😁', - fontSize: 50, - ), + return FlowyMobileStateContainer.info( + emoji: '😁', title: LocaleKeys.favorite_noFavorite.tr(), - message: LocaleKeys.favorite_noFavoriteHintText.tr(), + description: LocaleKeys.favorite_noFavoriteHintText.tr(), ); } return Scrollbar( child: SingleChildScrollView( - child: SlidableAutoCloseBehavior( - child: Column( - children: [ - MobileFavoriteFolder( - showHeader: false, - forceExpanded: true, - views: favoriteState.views, - ), - const VSpace(100.0), - ], + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SlidableAutoCloseBehavior( + child: Column( + children: [ + MobileFavoriteFolder( + showHeader: false, + forceExpanded: true, + views: favoriteState.views, + ), + const VSpace(100.0), + ], + ), ), ), ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart index 8f21404100b8..5541588d1008 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart @@ -19,7 +19,7 @@ class MobileFavoriteScreen extends StatelessWidget { Widget build(BuildContext context) { return FutureBuilder( future: Future.wait([ - FolderEventGetCurrentWorkspace().send(), + FolderEventGetCurrentWorkspaceSetting().send(), getIt().getUser(), ]), builder: (context, snapshots) { @@ -82,12 +82,9 @@ class MobileFavoritePage extends StatelessWidget { // Folder Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: MobileFavoritePageFolder( - userProfile: userProfile, - workspaceSetting: workspaceSetting, - ), + child: MobileFavoritePageFolder( + userProfile: userProfile, + workspaceSetting: workspaceSetting, ), ), ], diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart index 606206568278..f1d10ddc6788 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart @@ -29,12 +29,12 @@ class MobileFolders extends StatelessWidget { BlocProvider( create: (_) => MenuBloc( user: user, - workspace: workspaceSetting.workspace, + workspaceId: workspaceSetting.workspaceId, )..add(const MenuEvent.initial()), ), BlocProvider( create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), - ) + ), ], child: MultiBlocListener( listeners: [ diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart index 0c5db1dfd8f3..ec54775d6217 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart @@ -2,7 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/home/mobile_folders.dart'; import 'package:appflowy/mobile/presentation/home/mobile_home_page_header.dart'; -import 'package:appflowy/mobile/presentation/home/mobile_home_page_recent_files.dart'; +import 'package:appflowy/mobile/presentation/home/recent_folder/mobile_home_recent_views.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart'; @@ -24,7 +24,7 @@ class MobileHomeScreen extends StatelessWidget { Widget build(BuildContext context) { return FutureBuilder( future: Future.wait([ - FolderEventGetCurrentWorkspace().send(), + FolderEventGetCurrentWorkspaceSetting().send(), getIt().getUser(), ]), builder: (context, snapshots) { @@ -97,8 +97,7 @@ class MobileHomePage extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ // Recent files - const MobileHomePageRecentFilesWidget(), - const Divider(), + const MobileRecentFolder(), // Folders Padding( @@ -110,7 +109,10 @@ class MobileHomePage extends StatelessWidget { ), ), const SizedBox(height: 8), - const _TrashButton(), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: _TrashButton(), + ), ], ), ), @@ -142,7 +144,10 @@ class _TrashButton extends StatelessWidget { LocaleKeys.trash_text.tr(), style: Theme.of(context).textTheme.labelMedium, ), - style: const ButtonStyle(alignment: Alignment.centerLeft), + style: const ButtonStyle( + alignment: Alignment.centerLeft, + splashFactory: NoSplash.splashFactory, + ), ), ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart index 98777385b0e0..3cc4108b453a 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart @@ -66,7 +66,7 @@ class MobileHomePageHeader extends StatelessWidget { icon: const Icon( Icons.arrow_drop_down, ), - ) + ), ], ), FlowyText.regular( @@ -76,7 +76,7 @@ class MobileHomePageHeader extends StatelessWidget { fontSize: 12, color: theme.colorScheme.onSurface, overflow: TextOverflow.ellipsis, - ) + ), ], ), ), @@ -87,7 +87,7 @@ class MobileHomePageHeader extends StatelessWidget { icon: const FlowySvg( FlowySvgs.m_setting_m, ), - ) + ), ], ), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_recent_files.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_recent_files.dart deleted file mode 100644 index 9fa880553d98..000000000000 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_recent_files.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; - -// TODO(yijing): replace by real data later -class MockRecentFile { - MockRecentFile({ - required this.title, - }); - final String title; - final String icon = '🐼'; - - final image = Image.asset( - 'assets/images/app_flowy_abstract_cover_1.jpg', - fit: BoxFit.cover, - ); -} - -final recentFilesList = [ - MockRecentFile(title: 'Work out plan'), - MockRecentFile(title: 'Travel plan'), - MockRecentFile(title: 'Meeting notes'), - MockRecentFile(title: 'Recipes'), - MockRecentFile(title: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), -]; - -class MobileHomePageRecentFilesWidget extends StatelessWidget { - const MobileHomePageRecentFilesWidget({super.key}); - - @override - Widget build(BuildContext context) { - // TODO: implement the details later. - return SizedBox( - height: 168, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: FlowyText.semibold( - 'Recent', - fontSize: 20.0, - ), - ), - Expanded( - child: ListView.separated( - separatorBuilder: (context, index) => const HSpace(8), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - scrollDirection: Axis.horizontal, - itemCount: recentFilesList.length, - itemBuilder: (context, index) { - return Container( - width: 120, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.background, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context) - .colorScheme - .outline - .withOpacity(0.5), - ), - ), - child: Stack( - children: [ - Align( - alignment: Alignment.topCenter, - child: SizedBox( - height: 60, - width: double.infinity, - child: ClipRRect( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(8), - topRight: Radius.circular(8), - ), - child: recentFilesList[index].image, - ), - ), - ), - Align( - alignment: Alignment.centerLeft, - child: Container( - height: 32, - width: 32, - margin: const EdgeInsets.only(left: 8), - child: Text( - recentFilesList[index].icon, - style: const TextStyle(fontSize: 32), - ), - ), - ), - Align( - alignment: Alignment.bottomCenter, - child: Container( - height: 32, - width: double.infinity, - margin: const EdgeInsets.only( - left: 8, - right: 8, - bottom: 8, - ), - child: Text( - recentFilesList[index].title, - softWrap: true, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: Theme.of(context) - .colorScheme - .onBackground, - ), - maxLines: 2, - ), - ), - ), - ], - ), - ); - }, - ), - ), - ], - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart index 9f7289188547..0623b77430c3 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; - +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -23,33 +23,47 @@ class _MobileHomeSettingPageState extends State { return FutureBuilder( future: getIt().getUser(), builder: ((context, snapshot) { + String? errorMsg; if (!snapshot.hasData) { return const Center(child: CircularProgressIndicator.adaptive()); } - final userProfile = snapshot.data?.fold((error) => null, (userProfile) { + final userProfile = snapshot.data?.fold((error) { + errorMsg = error.msg; + return null; + }, (userProfile) { return userProfile; }); + return Scaffold( appBar: AppBar( title: Text(LocaleKeys.settings_title.tr()), ), - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - PersonalInfoSettingGroup( - userProfile: userProfile, + body: userProfile == null + ? FlowyMobileStateContainer.error( + emoji: '🛸', + title: LocaleKeys.settings_mobile_userprofileError.tr(), + description: LocaleKeys + .settings_mobile_userprofileErrorDescription + .tr(), + errorMsg: errorMsg, + ) + : SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + PersonalInfoSettingGroup( + userProfile: userProfile, + ), + // TODO(yijing): implement this along with Notification Page + const NotificationsSettingGroup(), + const AppearanceSettingGroup(), + const SupportSettingGroup(), + const AboutSettingGroup(), + ], + ), ), - // TODO(yijing): implement this along with Notification Page - const NotificationsSettingGroup(), - const AppearanceSettingGroup(), - const SupportSettingGroup(), - const AboutSettingGroup(), - ], - ), - ), - ), + ), ); }), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_trash_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_trash_page.dart index 989107e2ffea..1870402b01c6 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_trash_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_trash_page.dart @@ -1,12 +1,13 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_action_widget.dart'; -import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/trash/application/prelude.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:go_router/go_router.dart'; class MobileHomeTrashPage extends StatelessWidget { @@ -18,61 +19,53 @@ class MobileHomeTrashPage extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => getIt()..add(const TrashEvent.initial()), - child: Builder( - builder: (context) { + child: BlocBuilder( + builder: (context, state) { return Scaffold( appBar: AppBar( title: Text(LocaleKeys.trash_text.tr()), - elevation: 0, actions: [ - IconButton( - splashRadius: 20, - icon: const Icon(Icons.more_horiz), - onPressed: () { - showFlowyMobileBottomSheet( - context, - title: LocaleKeys.trash_mobile_actions.tr(), - builder: (_) => Row( - children: [ - Expanded( - child: BottomSheetActionWidget( - svg: FlowySvgs.m_restore_m, - text: LocaleKeys.trash_restoreAll.tr(), - onTap: () { - context - ..read() - .add(const TrashEvent.restoreAll()) - ..pop(); - }, + state.objects.isEmpty + ? const SizedBox.shrink() + : IconButton( + splashRadius: 20, + icon: const Icon(Icons.more_horiz), + onPressed: () { + final trashBloc = context.read(); + showFlowyMobileBottomSheet( + context, + title: LocaleKeys.trash_mobile_actions.tr(), + builder: (_) => Row( + children: [ + Expanded( + child: _TrashActionAllButton( + trashBloc: trashBloc, + type: _TrashActionType.deleteAll, + ), + ), + const SizedBox( + width: 16, + ), + Expanded( + child: _TrashActionAllButton( + trashBloc: trashBloc, + type: _TrashActionType.restoreAll, + ), + ), + ], ), - ), - Expanded( - child: BottomSheetActionWidget( - svg: FlowySvgs.m_delete_m, - text: LocaleKeys.trash_deleteAll.tr(), - onTap: () { - context - ..read() - .add(const TrashEvent.deleteAll()) - ..pop(); - }, - ), - ) - ], + ); + }, ), - ); - }, - ), ], ), - body: BlocBuilder( - builder: (_, state) { - if (state.objects.isEmpty) { - return const _TrashEmptyPage(); - } - return _DeletedFilesListView(state); - }, - ), + body: state.objects.isEmpty + ? FlowyMobileStateContainer.info( + emoji: '🗑️', + title: LocaleKeys.trash_mobile_empty.tr(), + description: LocaleKeys.trash_mobile_emptyDescription.tr(), + ) + : _DeletedFilesListView(state), ); }, ), @@ -80,104 +73,154 @@ class MobileHomeTrashPage extends StatelessWidget { } } -class _DeletedFilesListView extends StatelessWidget { - const _DeletedFilesListView( - this.state, - ); +enum _TrashActionType { + restoreAll, + deleteAll, +} + +class _TrashActionAllButton extends StatelessWidget { + /// Switch between 'delete all' and 'restore all' feature + const _TrashActionAllButton({ + this.type = _TrashActionType.deleteAll, + required this.trashBloc, + }); + final _TrashActionType type; + final TrashBloc trashBloc; - final TrashState state; @override Widget build(BuildContext context) { final theme = Theme.of(context); - return ListView.builder( - itemBuilder: (context, index) { - final object = state.objects[index]; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: ListTile( - // TODO(Yijing): implement file type after TrashPB has file type - leading: FlowySvg( - FlowySvgs.documents_s, - size: const Size.square(24), - color: theme.colorScheme.onSurface, - ), - title: Text( - object.name, - style: theme.textTheme.labelMedium - ?.copyWith(color: theme.colorScheme.onBackground), - ), - horizontalTitleGap: 0, - // TODO(yiing): needs improve by container/surface theme color - tileColor: theme.colorScheme.onSurface.withOpacity(0.1), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // TODO(yijing): extract icon button - IconButton( - splashRadius: 20, - icon: FlowySvg( - FlowySvgs.m_restore_m, - size: const Size.square(24), - color: theme.colorScheme.onSurface, - ), - onPressed: () { - context - .read() - .add(TrashEvent.putback(object.id)); - }, - ), - IconButton( - splashRadius: 20, - icon: FlowySvg( - FlowySvgs.m_delete_m, - size: const Size.square(24), - color: theme.colorScheme.onSurface, - ), - onPressed: () { - context.read().add(TrashEvent.delete(object)); - }, - ) - ], - ), - ), - ); - }, - itemCount: state.objects.length, + final isDeleteAll = type == _TrashActionType.deleteAll; + return BlocProvider.value( + value: trashBloc, + child: BottomSheetActionWidget( + svg: isDeleteAll ? FlowySvgs.m_delete_m : FlowySvgs.m_restore_m, + text: isDeleteAll + ? LocaleKeys.trash_deleteAll.tr() + : LocaleKeys.trash_restoreAll.tr(), + onTap: () { + final trashList = trashBloc.state.objects; + if (trashList.isNotEmpty) { + context.pop(); + showFlowyMobileConfirmDialog( + context, + title: isDeleteAll + ? LocaleKeys.trash_confirmDeleteAll_title.tr() + : LocaleKeys.trash_restoreAll.tr(), + content: isDeleteAll + ? LocaleKeys.trash_confirmDeleteAll_caption.tr() + : LocaleKeys.trash_confirmRestoreAll_caption.tr(), + actionButtonTitle: isDeleteAll + ? LocaleKeys.trash_deleteAll.tr() + : LocaleKeys.trash_restoreAll.tr(), + actionButtonColor: isDeleteAll + ? theme.colorScheme.error + : theme.colorScheme.primary, + onActionButtonPressed: () { + if (isDeleteAll) { + trashBloc.add( + const TrashEvent.deleteAll(), + ); + } else { + trashBloc.add( + const TrashEvent.restoreAll(), + ); + } + }, + cancelButtonTitle: LocaleKeys.button_cancel.tr(), + ); + } else { + // when there is no deleted files + // show toast + Fluttertoast.showToast( + msg: LocaleKeys.trash_mobile_empty.tr(), + gravity: ToastGravity.CENTER, + ); + } + }, + ), ); } } -class _TrashEmptyPage extends StatelessWidget { - const _TrashEmptyPage(); +class _DeletedFilesListView extends StatelessWidget { + const _DeletedFilesListView( + this.state, + ); + final TrashState state; @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - '🗑️', - style: TextStyle(fontSize: 40), - ), - const SizedBox(height: 8), - Text( - LocaleKeys.trash_mobile_empty.tr(), - style: theme.textTheme.labelLarge, - ), - const SizedBox(height: 4), - Text( - LocaleKeys.trash_mobile_emptyDescription.tr(), - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.hintColor, + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ListView.builder( + itemBuilder: (context, index) { + final object = state.objects[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: ListTile( + // TODO(Yijing): implement file type after TrashPB has file type + leading: FlowySvg( + FlowySvgs.documents_s, + size: const Size.square(24), + color: theme.colorScheme.onSurface, + ), + title: Text( + object.name, + style: theme.textTheme.labelMedium + ?.copyWith(color: theme.colorScheme.onBackground), + ), + horizontalTitleGap: 0, + // TODO(yiing): needs improve by container/surface theme color + tileColor: theme.colorScheme.onSurface.withOpacity(0.1), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // TODO(yijing): extract icon button + IconButton( + splashRadius: 20, + icon: FlowySvg( + FlowySvgs.m_restore_m, + size: const Size.square(24), + color: theme.colorScheme.onSurface, + ), + onPressed: () { + context + .read() + .add(TrashEvent.putback(object.id)); + Fluttertoast.showToast( + msg: + '${object.name} ${LocaleKeys.trash_mobile_isRestored.tr()}', + gravity: ToastGravity.BOTTOM, + ); + }, + ), + IconButton( + splashRadius: 20, + icon: FlowySvg( + FlowySvgs.m_delete_m, + size: const Size.square(24), + color: theme.colorScheme.onSurface, + ), + onPressed: () { + context.read().add(TrashEvent.delete(object)); + Fluttertoast.showToast( + msg: + '${object.name} ${LocaleKeys.trash_mobile_isDeleted.tr()}', + gravity: ToastGravity.BOTTOM, + ); + }, + ), + ], + ), ), - ), - ], + ); + }, + itemCount: state.objects.length, ), ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/personal_folder/mobile_home_personal_folder.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/personal_folder/mobile_home_personal_folder.dart index 72a2897b31e7..c57d3b259222 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/personal_folder/mobile_home_personal_folder.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/personal_folder/mobile_home_personal_folder.dart @@ -64,7 +64,7 @@ class MobilePersonalFolder extends StatelessWidget { MobilePaneActionType.more, ]), ), - ) + ), ], ); }, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_home_recent_views.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_home_recent_views.dart new file mode 100644 index 000000000000..b4dbd0491037 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_home_recent_views.dart @@ -0,0 +1,104 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/mobile/presentation/home/recent_folder/mobile_recent_view.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart'; +import 'package:dartz/dartz.dart' hide State; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; + +class MobileRecentFolder extends StatefulWidget { + const MobileRecentFolder({super.key}); + + @override + State createState() => _MobileRecentFolderState(); +} + +class _MobileRecentFolderState extends State { + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: getIt().lastPushedRouter, + builder: (context, value, child) { + return FutureBuilder>( + future: FolderEventReadRecentViews().send(), + builder: (context, snapshot) { + final recentViews = snapshot.data + ?.fold>( + (l) => l.items, + (r) => [], + ) + // only keep the first 10 items. + .reversed + .take(10) + .toList(); + + if (recentViews == null || recentViews.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + children: [ + _RecentViews( + key: ValueKey(recentViews), + // the recent views are in reverse order + recentViews: recentViews, + ), + const VSpace(12.0), + ], + ); + }, + ); + }, + ); + } +} + +class _RecentViews extends StatelessWidget { + const _RecentViews({ + super.key, + required this.recentViews, + }); + + final List recentViews; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 168, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FlowyText.semibold( + LocaleKeys.sideBar_recent.tr(), + fontSize: 20.0, + ), + ), + Expanded( + child: ListView.separated( + separatorBuilder: (context, index) => const HSpace(8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + scrollDirection: Axis.horizontal, + itemCount: recentViews.length, + itemBuilder: (context, index) { + return MobileRecentView( + view: recentViews[index], + height: 120, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart new file mode 100644 index 000000000000..de67e5b7dc1c --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart @@ -0,0 +1,208 @@ +import 'dart:io'; + +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/workspace/application/doc/doc_listener.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:string_validator/string_validator.dart'; + +class MobileRecentView extends StatefulWidget { + const MobileRecentView({ + super.key, + required this.view, + required this.height, + }); + + final ViewPB view; + final double height; + + @override + State createState() => _MobileRecentViewState(); +} + +class _MobileRecentViewState extends State { + late final ViewListener viewListener; + late ViewPB view; + late final DocumentListener documentListener; + + @override + void initState() { + super.initState(); + + view = widget.view; + + viewListener = ViewListener( + viewId: view.id, + )..start( + onViewUpdated: (view) { + setState(() { + this.view = view; + }); + }, + ); + + documentListener = DocumentListener(id: view.id) + ..start( + didReceiveUpdate: (document) { + setState(() { + view = view; + }); + }, + ); + } + + @override + void dispose() { + viewListener.stop(); + documentListener.stop(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final icon = view.icon.value; + final theme = Theme.of(context); + + return GestureDetector( + onTap: () => context.pushView(view), + child: Container( + height: widget.height, + width: widget.height, + decoration: BoxDecoration( + color: theme.colorScheme.background, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.outline.withOpacity(0.5), + ), + ), + child: Stack( + children: [ + Positioned( + top: 0, + left: 0, + right: 0, + child: SizedBox( + height: widget.height / 2.0, + width: double.infinity, + child: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + child: _buildCoverWidget(), + ), + ), + ), + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 4), + child: icon.isNotEmpty + ? FlowyText( + icon, + fontSize: 30.0, + ) + : SizedBox.square( + dimension: 32.0, + child: view.defaultIcon(), + ), + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + height: widget.height / 2.0, + width: double.infinity, + padding: const EdgeInsets.only( + left: 8.0, + top: 14.0, + right: 8.0, + ), + child: FlowyText( + view.name, + maxLines: 2, + fontSize: 16.0, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildCoverWidget() { + return FutureBuilder( + future: _getPageNode(), + builder: ((context, snapshot) { + final node = snapshot.data; + final placeholder = Container( + color: Theme.of(context).colorScheme.onSecondaryContainer, + ); + if (node == null) { + return placeholder; + } + final type = CoverType.fromString( + node.attributes[DocumentHeaderBlockKeys.coverType], + ); + final cover = + node.attributes[DocumentHeaderBlockKeys.coverDetails] as String?; + if (cover == null) { + return placeholder; + } + switch (type) { + case CoverType.file: + if (isURL(cover)) { + return CachedNetworkImage( + imageUrl: cover, + fit: BoxFit.cover, + ); + } + final imageFile = File(cover); + if (!imageFile.existsSync()) { + return placeholder; + } + return Image.file( + imageFile, + ); + case CoverType.asset: + return Image.asset( + cover, + fit: BoxFit.cover, + ); + case CoverType.color: + final color = cover.tryToColor() ?? Colors.white; + return Container( + color: color, + ); + case CoverType.none: + return placeholder; + } + }), + ); + } + + Future _getPageNode() async { + final data = await DocumentEventGetDocumentData( + OpenDocumentPayloadPB(documentId: view.id), + ).send(); + final document = data.fold((l) => l.toDocument(), (r) => null); + if (document != null) { + return document.root; + } + return null; + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart index d24806f24670..251332a7b231 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart @@ -1,8 +1,8 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_add_new_page.dart'; import 'package:appflowy/mobile/presentation/page_item/mobile_view_item_add_button.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.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'; @@ -303,11 +303,8 @@ class _SingleMobileInnerViewItemState extends State { _buildLeftIcon(), const HSpace(4), // icon - SizedBox.square( - dimension: 22, - child: widget.view.defaultIcon(), - ), - const HSpace(12), + _buildViewIconButton(), + const HSpace(8), // title Expanded( child: FlowyText.regular( @@ -315,7 +312,7 @@ class _SingleMobileInnerViewItemState extends State { fontSize: 18.0, overflow: TextOverflow.ellipsis, ), - ) + ), ]; // hover action @@ -356,6 +353,19 @@ class _SingleMobileInnerViewItemState extends State { return child; } + Widget _buildViewIconButton() { + final icon = widget.view.icon.value.isNotEmpty + ? FlowyText( + widget.view.icon.value, + fontSize: 24.0, + ) + : SizedBox.square( + dimension: 26.0, + child: widget.view.defaultIcon(), + ); + return icon; + } + // > button or · button // show > if the view is expandable. // show · if the view can't contain child views. @@ -385,20 +395,24 @@ class _SingleMobileInnerViewItemState extends State { Widget _buildViewAddButton(BuildContext context) { return MobileViewAddButton( onPressed: () { - showMobileBottomSheet( - context: context, - builder: (_) => AddNewPageWidgetBottomSheet( - view: widget.view, - onAction: (layout) { - context.pop(); - context.read().add( - ViewEvent.createView( - LocaleKeys.menuAppHeader_defaultNewPageName.tr(), - layout, - ), - ); - }, - ), + final title = widget.view.name; + showFlowyMobileBottomSheet( + context, + title: title, + builder: (_) { + return AddNewPageWidgetBottomSheet( + view: widget.view, + onAction: (layout) { + context.pop(); + context.read().add( + ViewEvent.createView( + LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + layout, + ), + ); + }, + ); + }, ); }, ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance_setting_group.dart index d040f4cd27a1..d85e5fe2731f 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance_setting_group.dart @@ -40,7 +40,7 @@ class _AppearanceSettingGroupState extends State { color: theme.colorScheme.onSurface, ), ), - const Icon(Icons.chevron_right) + const Icon(Icons.chevron_right), ], ), onTap: () { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/edit_username_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/edit_username_bottom_sheet.dart index 4a68f411f575..026fb8466c62 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/edit_username_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/edit_username_bottom_sheet.dart @@ -71,7 +71,7 @@ class _EditUsernameBottomSheetState extends State { onPressed: () { widget.context.pop(); }, - ) + ), ], ), const SizedBox( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart index 1f177bed22ec..a2807732a156 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart @@ -16,7 +16,7 @@ class PersonalInfoSettingGroup extends StatelessWidget { required this.userProfile, }); - final UserProfilePB? userProfile; + final UserProfilePB userProfile; @override Widget build(BuildContext context) { @@ -33,9 +33,9 @@ class PersonalInfoSettingGroup extends StatelessWidget { settingItemList: [ MobileSettingItem( name: userName, - subtitle: isCloudEnabled && userProfile != null + subtitle: isCloudEnabled ? Text( - userProfile!.email, + userProfile.email, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface, ), @@ -62,7 +62,7 @@ class PersonalInfoSettingGroup extends StatelessWidget { }, ); }, - ) + ), ], ); }, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart index 5fcbf62fe8ac..3995e11b983c 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart @@ -4,6 +4,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'widgets/widgets.dart'; @@ -14,32 +15,33 @@ class SupportSettingGroup extends StatelessWidget { @override Widget build(BuildContext context) { - return MobileSettingGroup( - groupTitle: LocaleKeys.settings_mobile_support.tr(), - settingItemList: [ - // 'Help Center' - MobileSettingItem( - name: LocaleKeys.settings_mobile_joinDiscord.tr(), - trailing: const Icon( - Icons.chevron_right, + return FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) => MobileSettingGroup( + groupTitle: LocaleKeys.settings_mobile_support.tr(), + settingItemList: [ + MobileSettingItem( + name: LocaleKeys.settings_mobile_joinDiscord.tr(), + trailing: const Icon( + Icons.chevron_right, + ), + onTap: () => safeLaunchUrl('https://discord.gg/JucBXeU2FE'), ), - onTap: () => safeLaunchUrl('https://discord.gg/JucBXeU2FE'), - ), - MobileSettingItem( - name: LocaleKeys.workspace_errorActions_reportIssue.tr(), - trailing: const Icon( - Icons.chevron_right, + MobileSettingItem( + name: LocaleKeys.workspace_errorActions_reportIssue.tr(), + trailing: const Icon( + Icons.chevron_right, + ), + onTap: () { + final String? version = snapshot.data?.version; + final String os = Platform.operatingSystem; + safeLaunchUrl( + 'https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&projects=&template=bug_report.yaml&title=[Bug]%20Mobile:%20&version=$version&os=$os', + ); + }, ), - onTap: () { - // TODO(yijing): get app version before release - const String version = 'Beta'; - final String os = Platform.operatingSystem; - safeLaunchUrl( - 'https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&projects=&template=bug_report.yaml&title=[Bug]%20Mobile:%20&version=$version&os=$os', - ); - }, - ), - ], + ], + ), ); } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_state_container.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_state_container.dart new file mode 100644 index 000000000000..383906955285 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_state_container.dart @@ -0,0 +1,102 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +enum _FlowyMobileStateContainerType { + info, + error, +} + +/// Used to display info(like empty state) or error state +/// error state has two buttons to report issue with error message or reach out on discord +class FlowyMobileStateContainer extends StatelessWidget { + const FlowyMobileStateContainer.error({ + this.emoji, + required this.title, + this.description, + required this.errorMsg, + super.key, + }) : _stateType = _FlowyMobileStateContainerType.error; + + const FlowyMobileStateContainer.info({ + this.emoji, + required this.title, + this.description, + super.key, + }) : errorMsg = null, + _stateType = _FlowyMobileStateContainerType.info; + + final String? emoji; + final String title; + final String? description; + final String? errorMsg; + final _FlowyMobileStateContainerType _stateType; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox.expand( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + emoji ?? '', + style: const TextStyle(fontSize: 40), + ), + const SizedBox(height: 8), + Text( + title, + style: theme.textTheme.labelLarge, + ), + const SizedBox(height: 4), + Text( + description ?? '', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.hintColor, + ), + ), + if (_stateType == _FlowyMobileStateContainerType.error) ...[ + const SizedBox(height: 8), + FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + OutlinedButton( + onPressed: () { + final String? version = snapshot.data?.version; + final String os = Platform.operatingSystem; + safeLaunchUrl( + 'https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&projects=&template=bug_report.yaml&title=[Bug]%20Mobile:%20&version=$version&os=$os&context=Error%20log:%20$errorMsg', + ); + }, + child: Text( + LocaleKeys.workspace_errorActions_reportIssue.tr(), + ), + ), + OutlinedButton( + onPressed: () => + safeLaunchUrl('https://discord.gg/JucBXeU2FE'), + child: Text( + LocaleKeys.workspace_errorActions_reachOut.tr(), + ), + ), + ], + ); + }, + ), + ], + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/show_flowy_mobile_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/show_flowy_mobile_bottom_sheet.dart index 58d13ce921a6..78086f413fc8 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/show_flowy_mobile_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/show_flowy_mobile_bottom_sheet.dart @@ -8,6 +8,7 @@ Future showFlowyMobileBottomSheet( }) async { return showModalBottomSheet( context: context, + isScrollControlled: true, builder: (context) => Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), child: Column( @@ -47,7 +48,7 @@ class _BottomSheetTitle extends StatelessWidget { onPressed: () { context.pop(); }, - ) + ), ], ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart new file mode 100644 index 000000000000..4966c1d9cd95 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart @@ -0,0 +1,62 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +///show the dialog to confirm one single action +///[onActionButtonPressed] and [onCancelButtonPressed] end with close the dialog +Future showFlowyMobileConfirmDialog( + BuildContext context, { + String? title, + String? content, + required String actionButtonTitle, + Color? actionButtonColor, + String? cancelButtonTitle, + required void Function()? onActionButtonPressed, + void Function()? onCancelButtonPressed, +}) async { + return showDialog( + context: context, + builder: (dialogContext) { + final foregroundColor = Theme.of(context).colorScheme.onSurface; + return AlertDialog( + title: Text( + title ?? "", + ), + content: Text( + content ?? "", + ), + actions: [ + TextButton( + child: Text( + actionButtonTitle, + style: TextStyle( + color: actionButtonColor ?? foregroundColor, + ), + ), + onPressed: () { + onActionButtonPressed?.call(); + // we cannot use dialogContext.pop() here because this is no GoRouter in dialogContext. Use Navigator instead to close the dialog. + Navigator.of( + dialogContext, + ).pop(); + }, + ), + TextButton( + child: Text( + cancelButtonTitle ?? LocaleKeys.button_cancel.tr(), + style: TextStyle( + color: foregroundColor, + ), + ), + onPressed: () { + onCancelButtonPressed?.call(); + Navigator.of( + dialogContext, + ).pop(); + }, + ), + ], + ); + }, + ); +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/widgets.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/widgets.dart new file mode 100644 index 000000000000..8c8b0ad0bd44 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/widgets.dart @@ -0,0 +1,3 @@ +export 'show_flowy_mobile_confirm_dialog.dart'; +export 'show_flowy_mobile_bottom_sheet.dart'; +export 'flowy_mobile_state_container.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart new file mode 100644 index 000000000000..4989bdb04321 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart @@ -0,0 +1,95 @@ +import 'package:appflowy/plugins/base/emoji/emoji_picker_header.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_search_bar.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; + +// use a global value to store the selected emoji to prevent reloading every time. +EmojiData? _cachedEmojiData; + +class FlowyEmojiPicker extends StatefulWidget { + const FlowyEmojiPicker({ + super.key, + required this.onEmojiSelected, + }); + + final EmojiSelectedCallback onEmojiSelected; + + @override + State createState() => _FlowyEmojiPickerState(); +} + +class _FlowyEmojiPickerState extends State { + EmojiData? emojiData; + + @override + void initState() { + super.initState(); + + // load the emoji data from cache if it's available + if (_cachedEmojiData != null) { + emojiData = _cachedEmojiData; + } else { + EmojiData.builtIn().then( + (value) { + _cachedEmojiData = value; + setState(() { + emojiData = value; + }); + }, + ); + } + } + + @override + Widget build(BuildContext context) { + if (emojiData == null) { + return const Center( + child: SizedBox.square( + dimension: 24.0, + child: CircularProgressIndicator( + strokeWidth: 2.0, + ), + ), + ); + } + + return EmojiPicker( + emojiData: emojiData!, + configuration: EmojiPickerConfiguration( + showSectionHeader: true, + showTabs: false, + defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none, + ), + onEmojiSelected: widget.onEmojiSelected, + headerBuilder: (context, category) { + return FlowyEmojiHeader( + category: category, + ); + }, + itemBuilder: (context, emojiId, emoji, callback) { + return FlowyIconButton( + iconPadding: const EdgeInsets.all(2.0), + icon: FlowyText( + emoji, + fontSize: 28.0, + ), + onPressed: () => callback(emojiId, emoji), + ); + }, + searchBarBuilder: (context, keyword, skinTone) { + return FlowyEmojiSearchBar( + emojiData: emojiData!, + onKeywordChanged: (value) { + keyword.value = value; + }, + onSkinToneChanged: (value) { + skinTone.value = value; + }, + onRandomEmojiSelected: widget.onEmojiSelected, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_header.dart b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_header.dart new file mode 100644 index 000000000000..9619f00d30dc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_header.dart @@ -0,0 +1,48 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; + +class FlowyEmojiHeader extends StatelessWidget { + const FlowyEmojiHeader({ + super.key, + required this.category, + }); + + final Category category; + + @override + Widget build(BuildContext context) { + if (PlatformExtension.isDesktopOrWeb) { + return Container( + height: 22, + padding: const EdgeInsets.symmetric(horizontal: 8.0), + color: Theme.of(context).cardColor, + child: FlowyText.regular(category.id), + ); + } else { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 40, + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 8.0), + color: Theme.of(context).cardColor, + child: Padding( + padding: const EdgeInsets.only( + top: 14.0, + bottom: 4.0, + ), + child: FlowyText.regular(category.id), + ), + ), + const Divider( + height: 1, + thickness: 1, + ), + ], + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_i18n.dart b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_i18n.dart new file mode 100644 index 000000000000..13ba942f4947 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_i18n.dart @@ -0,0 +1,41 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; + +class FlowyEmojiPickerI18n extends EmojiPickerI18n { + @override + String get activity => LocaleKeys.emoji_categories_activities.tr(); + + @override + String get flags => LocaleKeys.emoji_categories_flags.tr(); + + @override + String get foods => LocaleKeys.emoji_categories_food.tr(); + + @override + String get frequent => LocaleKeys.emoji_categories_frequentlyUsed.tr(); + + @override + String get nature => LocaleKeys.emoji_categories_nature.tr(); + + @override + String get objects => LocaleKeys.emoji_categories_objects.tr(); + + @override + String get people => LocaleKeys.emoji_categories_smileys.tr(); + + @override + String get places => LocaleKeys.emoji_categories_places.tr(); + + @override + String get search => LocaleKeys.emoji_search.tr(); + + @override + String get symbols => LocaleKeys.emoji_categories_symbols.tr(); + + @override + String get searchHintText => LocaleKeys.emoji_search.tr(); + + @override + String get searchNoResult => LocaleKeys.emoji_noEmojiFound.tr(); +} diff --git a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_screen.dart b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_screen.dart new file mode 100644 index 000000000000..6aaa307ef5bb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_screen.dart @@ -0,0 +1,21 @@ +import 'package:appflowy/plugins/base/icon/icon_picker.dart'; +import 'package:appflowy/plugins/base/icon/icon_picker_page.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class MobileEmojiPickerScreen extends StatelessWidget { + static const routeName = '/emoji_picker'; + + const MobileEmojiPickerScreen({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return IconPickerPage( + onSelected: (result) { + context.pop(result); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_search_bar.dart b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_search_bar.dart new file mode 100644 index 000000000000..34e897901af3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_search_bar.dart @@ -0,0 +1,156 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; + +typedef EmojiKeywordChangedCallback = void Function(String keyword); +typedef EmojiSkinToneChanged = void Function(EmojiSkinTone skinTone); + +class FlowyEmojiSearchBar extends StatefulWidget { + const FlowyEmojiSearchBar({ + super.key, + required this.emojiData, + required this.onKeywordChanged, + required this.onSkinToneChanged, + required this.onRandomEmojiSelected, + }); + + final EmojiData emojiData; + final EmojiKeywordChangedCallback onKeywordChanged; + final EmojiSkinToneChanged onSkinToneChanged; + final EmojiSelectedCallback onRandomEmojiSelected; + + @override + State createState() => _FlowyEmojiSearchBarState(); +} + +class _FlowyEmojiSearchBarState extends State { + final TextEditingController controller = TextEditingController(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric( + vertical: 8.0, + horizontal: PlatformExtension.isDesktopOrWeb ? 0.0 : 8.0, + ), + child: Row( + children: [ + Expanded( + child: _SearchTextField( + onKeywordChanged: widget.onKeywordChanged, + ), + ), + const HSpace(6.0), + _RandomEmojiButton( + emojiData: widget.emojiData, + onRandomEmojiSelected: widget.onRandomEmojiSelected, + ), + const HSpace(6.0), + FlowyEmojiSkinToneSelector( + onEmojiSkinToneChanged: widget.onSkinToneChanged, + ), + const HSpace(6.0), + ], + ), + ); + } +} + +class _RandomEmojiButton extends StatelessWidget { + const _RandomEmojiButton({ + required this.emojiData, + required this.onRandomEmojiSelected, + }); + + final EmojiData emojiData; + final EmojiSelectedCallback onRandomEmojiSelected; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.emoji_random.tr(), + child: FlowyButton( + useIntrinsicWidth: true, + text: const Icon( + Icons.shuffle_rounded, + ), + onTap: () { + final random = emojiData.random; + onRandomEmojiSelected( + random.$1, + random.$2, + ); + }, + ), + ); + } +} + +class _SearchTextField extends StatefulWidget { + const _SearchTextField({ + required this.onKeywordChanged, + }); + + final EmojiKeywordChangedCallback onKeywordChanged; + + @override + State<_SearchTextField> createState() => _SearchTextFieldState(); +} + +class _SearchTextFieldState extends State<_SearchTextField> { + final TextEditingController controller = TextEditingController(); + + @override + void dispose() { + controller.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 32.0, + ), + child: FlowyTextField( + autoFocus: true, + hintText: LocaleKeys.emoji_search.tr(), + controller: controller, + onChanged: widget.onKeywordChanged, + prefixIcon: const Padding( + padding: EdgeInsets.only( + left: 8.0, + right: 4.0, + ), + child: FlowySvg( + FlowySvgs.search_s, + ), + ), + prefixIconConstraints: const BoxConstraints( + maxHeight: 18.0, + ), + suffixIcon: Padding( + padding: const EdgeInsets.all(4.0), + child: FlowyButton( + text: const FlowySvg( + FlowySvgs.close_lg, + ), + margin: EdgeInsets.zero, + useIntrinsicWidth: true, + onTap: () { + controller.clear(); + widget.onKeywordChanged(''); + }, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_skin_tone.dart b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_skin_tone.dart new file mode 100644 index 000000000000..e8da112660b4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_skin_tone.dart @@ -0,0 +1,102 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; + +// use a temporary global value to store last selected skin tone +EmojiSkinTone? lastSelectedEmojiSkinTone; + +@visibleForTesting +ValueKey emojiSkinToneKey(String icon) { + return ValueKey('emoji_skin_tone_$icon'); +} + +class FlowyEmojiSkinToneSelector extends StatefulWidget { + const FlowyEmojiSkinToneSelector({ + super.key, + required this.onEmojiSkinToneChanged, + }); + + final EmojiSkinToneChanged onEmojiSkinToneChanged; + + @override + State createState() => + _FlowyEmojiSkinToneSelectorState(); +} + +class _FlowyEmojiSkinToneSelectorState + extends State { + EmojiSkinTone skinTone = EmojiSkinTone.none; + final controller = PopoverController(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + direction: PopoverDirection.bottomWithCenterAligned, + controller: controller, + popupBuilder: (context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: EmojiSkinTone.values + .map( + (e) => _buildIconButton( + e.icon, + () { + setState(() => lastSelectedEmojiSkinTone = e); + widget.onEmojiSkinToneChanged(e); + controller.close(); + }, + ), + ) + .toList(), + ); + }, + child: FlowyTooltip( + message: LocaleKeys.emoji_selectSkinTone.tr(), + child: _buildIconButton( + lastSelectedEmojiSkinTone?.icon ?? '✋', + () => controller.show(), + ), + ), + ); + } + + Widget _buildIconButton(String icon, VoidCallback onPressed) { + return FlowyIconButton( + key: emojiSkinToneKey(icon), + icon: Padding( + // add a left padding to align the emoji center + padding: const EdgeInsets.only( + left: 3.0, + ), + child: FlowyText( + icon, + fontSize: 22.0, + ), + ), + onPressed: onPressed, + ); + } +} + +extension EmojiSkinToneIcon on EmojiSkinTone { + String get icon { + switch (this) { + case EmojiSkinTone.none: + return '✋'; + case EmojiSkinTone.light: + return '✋🏻'; + case EmojiSkinTone.mediumLight: + return '✋🏼'; + case EmojiSkinTone.medium: + return '✋🏽'; + case EmojiSkinTone.mediumDark: + return '✋🏾'; + case EmojiSkinTone.dark: + return '✋🏿'; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker.dart b/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker.dart new file mode 100644 index 000000000000..818a3bcbf706 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker.dart @@ -0,0 +1,141 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; + +enum FlowyIconType { + emoji, + icon, + custom; +} + +class EmojiPickerResult { + const EmojiPickerResult( + this.type, + this.emoji, + ); + + final FlowyIconType type; + final String emoji; +} + +class FlowyIconPicker extends StatefulWidget { + const FlowyIconPicker({ + super.key, + required this.onSelected, + }); + + final void Function(EmojiPickerResult result) onSelected; + + @override + State createState() => _FlowyIconPickerState(); +} + +class _FlowyIconPickerState extends State + with SingleTickerProviderStateMixin { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + // ONLY supports emoji picker for now + return DefaultTabController( + length: 1, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + _buildTabs(context), + const Spacer(), + _RemoveIconButton( + onTap: () { + widget.onSelected( + const EmojiPickerResult( + FlowyIconType.icon, + '', + ), + ); + }, + ), + ], + ), + const Divider( + height: 2, + ), + Expanded( + child: TabBarView( + children: [ + FlowyEmojiPicker( + onEmojiSelected: (_, emoji) { + widget.onSelected( + EmojiPickerResult( + FlowyIconType.emoji, + emoji, + ), + ); + }, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildTabs(BuildContext context) { + return Align( + alignment: Alignment.centerLeft, + child: TabBar( + indicatorSize: TabBarIndicatorSize.label, + isScrollable: true, + overlayColor: MaterialStatePropertyAll( + Theme.of(context).colorScheme.secondary, + ), + padding: EdgeInsets.zero, + tabs: [ + FlowyHover( + style: const HoverStyle(borderRadius: BorderRadius.zero), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + child: FlowyText( + LocaleKeys.emoji_emojiTab.tr(), + ), + ), + ), + ], + ), + ); + } +} + +class _RemoveIconButton extends StatelessWidget { + const _RemoveIconButton({ + required this.onTap, + }); + + final VoidCallback onTap; + @override + Widget build(BuildContext context) { + return SizedBox( + height: 28, + child: FlowyButton( + onTap: onTap, + useIntrinsicWidth: true, + text: FlowyText( + LocaleKeys.document_plugins_cover_removeIcon.tr(), + ), + leftIcon: const FlowySvg(FlowySvgs.delete_s), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker_page.dart b/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker_page.dart new file mode 100644 index 000000000000..292910b6f4ef --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker_page.dart @@ -0,0 +1,40 @@ +import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; +import 'package:appflowy/plugins/base/icon/icon_picker.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class IconPickerPage extends StatefulWidget { + const IconPickerPage({ + super.key, + required this.onSelected, + }); + + final void Function(EmojiPickerResult) onSelected; + + @override + State createState() => _IconPickerPageState(); +} + +class _IconPickerPageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + titleSpacing: 0, + title: const FlowyText.semibold( + 'Page icon', + fontSize: 14.0, + ), + leading: AppBarBackButton( + onTap: () => context.pop(), + ), + ), + body: SafeArea( + child: FlowyIconPicker( + onSelected: widget.onSelected, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/application/cell/cell_controller.dart b/frontend/appflowy_flutter/lib/plugins/database_view/application/cell/cell_controller.dart index b37e990f21fa..40c644eec315 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/application/cell/cell_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/application/cell/cell_controller.dart @@ -219,7 +219,7 @@ class CellController extends Equatable { @override List get props => [ _cellCache.get(_cacheKey) ?? "", - _cellContext.rowId + _cellContext.fieldInfo.id + _cellContext.rowId + _cellContext.fieldInfo.id, ]; } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/application/database_controller.dart b/frontend/appflowy_flutter/lib/plugins/database_view/application/database_controller.dart index 6616dd80d1ae..8cf1d7d4c1cd 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/application/database_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/application/database_controller.dart @@ -1,7 +1,7 @@ import 'package:appflowy/plugins/database_view/application/field/field_controller.dart'; import 'package:appflowy/plugins/database_view/application/view/view_cache.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/board_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/calendar_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; @@ -47,12 +47,10 @@ class GroupCallbacks { } class DatabaseLayoutSettingCallbacks { - final void Function(DatabaseLayoutSettingPB) onLayoutChanged; - final void Function(DatabaseLayoutSettingPB) onLoadLayout; + final void Function(DatabaseLayoutSettingPB) onLayoutSettingsChanged; DatabaseLayoutSettingCallbacks({ - required this.onLayoutChanged, - required this.onLoadLayout, + required this.onLayoutSettingsChanged, }); } @@ -125,11 +123,11 @@ class DatabaseController { void addListener({ DatabaseCallbacks? onDatabaseChanged, - DatabaseLayoutSettingCallbacks? onLayoutChanged, + DatabaseLayoutSettingCallbacks? onLayoutSettingsChanged, GroupCallbacks? onGroupChanged, }) { - if (onLayoutChanged != null) { - _layoutCallbacks.add(onLayoutChanged); + if (onLayoutSettingsChanged != null) { + _layoutCallbacks.add(onLayoutSettingsChanged); } if (onDatabaseChanged != null) { @@ -179,6 +177,7 @@ class DatabaseController { Future> createRow({ RowId? startRowId, String? groupId, + bool fromBeginning = false, void Function(RowDataBuilder builder)? withCells, }) { Map? cellDataByFieldId; @@ -193,6 +192,7 @@ class DatabaseController { startRowId: startRowId, groupId: groupId, cellDataByFieldId: cellDataByFieldId, + fromBeginning: fromBeginning, ); } @@ -228,12 +228,14 @@ class DatabaseController { ); } - Future updateLayoutSetting( - CalendarLayoutSettingPB calendarlLayoutSetting, - ) async { + Future updateLayoutSetting({ + BoardLayoutSettingPB? boardLayoutSetting, + CalendarLayoutSettingPB? calendarLayoutSetting, + }) async { await _databaseViewBackendSvc .updateLayoutSetting( - calendarLayoutSetting: calendarlLayoutSetting, + boardLayoutSetting: boardLayoutSetting, + calendarLayoutSetting: calendarLayoutSetting, layoutType: databaseLayout, ) .then((result) { @@ -241,15 +243,6 @@ class DatabaseController { }); } - void updateGroupConfiguration(bool hideUngrouped) async { - final payload = GroupSettingChangesetPB( - viewId: viewId, - groupConfigurationId: "", - hideUngrouped: hideUngrouped, - ); - DatabaseEventUpdateGroupConfiguration(payload).send(); - } - Future dispose() async { await _databaseViewBackendSvc.closeView(); await fieldController.dispose(); @@ -261,9 +254,6 @@ class DatabaseController { } Future _loadGroups() async { - final configResult = await loadGroupConfigurations(viewId: viewId); - _handleGroupConfigurationChanged(configResult); - final groupsResult = await _databaseViewBackendSvc.loadGroups(); groupsResult.fold( (groups) { @@ -280,10 +270,9 @@ class DatabaseController { result.fold( (newDatabaseLayoutSetting) { databaseLayoutSetting = newDatabaseLayoutSetting; - databaseLayoutSetting?.freeze(); for (final callback in _layoutCallbacks) { - callback.onLoadLayout(newDatabaseLayoutSetting); + callback.onLayoutSettingsChanged(newDatabaseLayoutSetting); } }, (r) => Log.error(r), @@ -339,7 +328,6 @@ class DatabaseController { void _listenOnGroupChanged() { _groupListener.start( - onGroupConfigurationChanged: _handleGroupConfigurationChanged, onNumOfGroupsChanged: (result) { result.fold( (changeset) { @@ -386,7 +374,7 @@ class DatabaseController { databaseLayoutSetting?.freeze(); for (final callback in _layoutCallbacks) { - callback.onLayoutChanged(newLayout); + callback.onLayoutSettingsChanged(newLayout); } }, (r) => Log.error(r), @@ -394,29 +382,6 @@ class DatabaseController { }, ); } - - Future, FlowyError>> loadGroupConfigurations({ - required String viewId, - }) { - final payload = DatabaseViewIdPB(value: viewId); - - return DatabaseEventGetGroupConfigurations(payload).send().then((result) { - return result.fold((l) => left(l.items), (r) => right(r)); - }); - } - - void _handleGroupConfigurationChanged( - Either, FlowyError> result, - ) { - result.fold( - (configurations) { - for (final callback in _groupCallbacks) { - callback.onGroupConfigurationChanged?.call(configurations); - } - }, - (r) => Log.error(r), - ); - } } class RowDataBuilder { diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/application/database_view_service.dart b/frontend/appflowy_flutter/lib/plugins/database_view/application/database_view_service.dart index 623f99b94d28..bdada95d69d1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/application/database_view_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/application/database_view_service.dart @@ -1,4 +1,5 @@ import 'package:appflowy/plugins/database_view/application/row/row_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/board_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/calendar_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/group_changeset.pb.dart'; @@ -34,9 +35,13 @@ class DatabaseViewBackendService { RowId? startRowId, String? groupId, Map? cellDataByFieldId, + bool fromBeginning = false, }) { final payload = CreateRowPayloadPB.create()..viewId = viewId; - payload.startRowId = startRowId ?? ""; + + if (!fromBeginning || startRowId != null) { + payload.startRowId = startRowId ?? ""; + } if (groupId != null) { payload.groupId = groupId; @@ -114,11 +119,17 @@ class DatabaseViewBackendService { Future> updateLayoutSetting({ required DatabaseLayoutPB layoutType, + BoardLayoutSettingPB? boardLayoutSetting, CalendarLayoutSettingPB? calendarLayoutSetting, }) { final payload = LayoutSettingChangesetPB.create() ..viewId = viewId ..layoutType = layoutType; + + if (boardLayoutSetting != null) { + payload.board = boardLayoutSetting; + } + if (calendarLayoutSetting != null) { payload.calendar = calendarLayoutSetting; } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_action_sheet_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_action_sheet_bloc.dart index d72ced9fbe3b..929cbb9a3ce3 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_action_sheet_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_action_sheet_bloc.dart @@ -4,6 +4,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'field_info.dart'; import 'field_service.dart'; part 'field_action_sheet_bloc.freezed.dart'; @@ -14,17 +15,18 @@ class FieldActionSheetBloc final FieldBackendService fieldService; final FieldSettingsBackendService fieldSettingsService; - FieldActionSheetBloc({required FieldContext fieldCellContext}) - : fieldId = fieldCellContext.fieldInfo.id, + FieldActionSheetBloc({ + required String viewId, + required FieldInfo fieldInfo, + }) : fieldId = fieldInfo.id, fieldService = FieldBackendService( - viewId: fieldCellContext.viewId, - fieldId: fieldCellContext.fieldInfo.id, + viewId: viewId, + fieldId: fieldInfo.id, ), - fieldSettingsService = - FieldSettingsBackendService(viewId: fieldCellContext.viewId), + fieldSettingsService = FieldSettingsBackendService(viewId: viewId), super( FieldActionSheetState.initial( - TypeOptionPB.create()..field_2 = fieldCellContext.fieldInfo.field, + TypeOptionPB.create()..field_2 = fieldInfo.field, ), ) { on( diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_cell_bloc.dart index b916177a55ce..807f683c5ac8 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_cell_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_cell_bloc.dart @@ -1,77 +1,55 @@ import 'dart:math'; -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; +import 'package:appflowy/plugins/database_view/application/field_settings/field_settings_service.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'field_listener.dart'; -import 'field_service.dart'; +import 'field_info.dart'; part 'field_cell_bloc.freezed.dart'; class FieldCellBloc extends Bloc { - final SingleFieldListener _fieldListener; - final FieldBackendService _fieldBackendSvc; + FieldInfo fieldInfo; + final FieldSettingsBackendService _fieldSettingsService; - FieldCellBloc({ - required FieldContext fieldContext, - }) : _fieldListener = - SingleFieldListener(fieldId: fieldContext.fieldInfo.id), - _fieldBackendSvc = FieldBackendService( - viewId: fieldContext.viewId, - fieldId: fieldContext.fieldInfo.id, + FieldCellBloc({required String viewId, required this.fieldInfo}) + : _fieldSettingsService = FieldSettingsBackendService( + viewId: viewId, ), - super(FieldCellState.initial(fieldContext)) { + super(FieldCellState.initial(fieldInfo)) { on( (event, emit) async { event.when( - initial: () { - _startListening(); - }, - didReceiveFieldUpdate: (field) { - emit(state.copyWith(field: fieldContext.fieldInfo.field)); + onFieldChanged: (newFieldInfo) { + fieldInfo = newFieldInfo; + emit(FieldCellState.initial(newFieldInfo)); }, onResizeStart: () { - emit(state.copyWith(resizeStart: state.width)); + emit(state.copyWith(isResizing: true, resizeStart: state.width)); }, startUpdateWidth: (offset) { final width = max(offset + state.resizeStart, 50).toDouble(); emit(state.copyWith(width: width)); }, endUpdateWidth: () { - if (state.width != state.field.width.toDouble()) { - _fieldBackendSvc.updateField(width: state.width); + if (state.width != fieldInfo.fieldSettings?.width.toDouble()) { + _fieldSettingsService.updateFieldSettings( + fieldId: fieldInfo.id, + width: state.width, + ); } + emit(state.copyWith(isResizing: false, resizeStart: 0)); }, ); }, ); } - - @override - Future close() async { - await _fieldListener.stop(); - return super.close(); - } - - void _startListening() { - _fieldListener.start( - onFieldChanged: (updatedField) { - if (isClosed) { - return; - } - add(FieldCellEvent.didReceiveFieldUpdate(updatedField)); - }, - ); - } } @freezed class FieldCellEvent with _$FieldCellEvent { - const factory FieldCellEvent.initial() = _InitialCell; - const factory FieldCellEvent.didReceiveFieldUpdate(FieldPB field) = - _DidReceiveFieldUpdate; + const factory FieldCellEvent.onFieldChanged(FieldInfo newFieldInfo) = + _OnFieldChanged; const factory FieldCellEvent.onResizeStart() = _OnResizeStart; const factory FieldCellEvent.startUpdateWidth(double offset) = _StartUpdateWidth; @@ -81,16 +59,16 @@ class FieldCellEvent with _$FieldCellEvent { @freezed class FieldCellState with _$FieldCellState { const factory FieldCellState({ - required String viewId, - required FieldPB field, + required FieldInfo fieldInfo, required double width, + required bool isResizing, required double resizeStart, }) = _FieldCellState; - factory FieldCellState.initial(FieldContext cellContext) => FieldCellState( - viewId: cellContext.viewId, - field: cellContext.fieldInfo.field, - width: cellContext.fieldInfo.field.width.toDouble(), + factory FieldCellState.initial(FieldInfo fieldInfo) => FieldCellState( + fieldInfo: fieldInfo, + isResizing: false, + width: fieldInfo.fieldSettings!.width.toDouble(), resizeStart: 0, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_controller.dart b/frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_controller.dart index 7556640fb9e6..4c660dc25cc5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_controller.dart @@ -392,7 +392,7 @@ class FieldController { } final List newFields = fieldInfos; final Map deletedFieldMap = { - for (final fieldOrder in deletedFields) fieldOrder.fieldId: fieldOrder + for (final fieldOrder in deletedFields) fieldOrder.fieldId: fieldOrder, }; newFields.retainWhere((field) => (deletedFieldMap[field.id] == null)); diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_service.dart b/frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_service.dart index 6cf15e8b44c2..9d83019063ff 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_service.dart @@ -1,12 +1,8 @@ -import 'package:appflowy/plugins/database_view/application/field/field_info.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart'; import 'package:dartz/dartz.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'field_service.freezed.dart'; /// FieldService consists of lots of event functions. We define the events in the backend(Rust), /// you can find the corresponding event implementation in event_map.rs of the corresponding crate. @@ -104,11 +100,3 @@ class FieldBackendService { return DatabaseEventGetPrimaryField(payload).send(); } } - -@freezed -class FieldContext with _$FieldContext { - const factory FieldContext({ - required String viewId, - required FieldInfo fieldInfo, - }) = _FieldCellContext; -} diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/application/field_settings/field_settings_service.dart b/frontend/appflowy_flutter/lib/plugins/database_view/application/field_settings/field_settings_service.dart index e699b61ad842..8c66949958d1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/application/field_settings/field_settings_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/application/field_settings/field_settings_service.dart @@ -58,6 +58,7 @@ class FieldSettingsBackendService { Future> updateFieldSettings({ required String fieldId, FieldVisibility? fieldVisibility, + double? width, }) { final FieldSettingsChangesetPB payload = FieldSettingsChangesetPB.create() ..viewId = viewId @@ -67,6 +68,10 @@ class FieldSettingsBackendService { payload.visibility = fieldVisibility; } + if (width != null) { + payload.width = width.round(); + } + return DatabaseEventUpdateFieldSettings(payload).send(); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/application/group/group_listener.dart b/frontend/appflowy_flutter/lib/plugins/database_view/application/group/group_listener.dart index bd419da29b0c..a07b287f43d1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/application/group/group_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/application/group/group_listener.dart @@ -15,8 +15,6 @@ typedef GroupByNewFieldValue = Either, FlowyError>; class DatabaseGroupListener { final String viewId; - PublishNotifier? _groupConfigurationNotifier = - PublishNotifier(); PublishNotifier? _numOfGroupsNotifier = PublishNotifier(); PublishNotifier? _groupByFieldNotifier = PublishNotifier(); @@ -24,13 +22,9 @@ class DatabaseGroupListener { DatabaseGroupListener(this.viewId); void start({ - required void Function(GroupConfigurationUpdateValue) - onGroupConfigurationChanged, required void Function(GroupUpdateValue) onNumOfGroupsChanged, required void Function(GroupByNewFieldValue) onGroupByNewField, }) { - _groupConfigurationNotifier - ?.addPublishListener(onGroupConfigurationChanged); _numOfGroupsNotifier?.addPublishListener(onNumOfGroupsChanged); _groupByFieldNotifier?.addPublishListener(onGroupByNewField); _listener = DatabaseNotificationListener( @@ -44,13 +38,6 @@ class DatabaseGroupListener { Either result, ) { switch (ty) { - case DatabaseNotification.DidUpdateGroupConfiguration: - result.fold( - (payload) => _groupConfigurationNotifier?.value = - left(RepeatedGroupSettingPB.fromBuffer(payload).items), - (error) => _groupConfigurationNotifier?.value = right(error), - ); - break; case DatabaseNotification.DidUpdateNumOfGroups: result.fold( (payload) => _numOfGroupsNotifier?.value = @@ -72,9 +59,6 @@ class DatabaseGroupListener { Future stop() async { await _listener?.stop(); - _groupConfigurationNotifier?.dispose(); - _groupConfigurationNotifier = null; - _numOfGroupsNotifier?.dispose(); _numOfGroupsNotifier = null; diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/application/group/group_service.dart b/frontend/appflowy_flutter/lib/plugins/database_view/application/group/group_service.dart index 7b14af888613..26233317b0c1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/application/group/group_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/application/group/group_service.dart @@ -37,4 +37,15 @@ class GroupBackendService { } return DatabaseEventUpdateGroup(payload).send(); } + + Future> createGroup({ + required String name, + String groupConfigId = "", + }) { + final payload = CreateGroupPayloadPB.create() + ..viewId = viewId + ..name = name; + + return DatabaseEventCreateGroup(payload).send(); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/application/layout/calendar_setting_listener.dart b/frontend/appflowy_flutter/lib/plugins/database_view/application/layout/calendar_setting_listener.dart deleted file mode 100644 index 57e2c112a0b1..000000000000 --- a/frontend/appflowy_flutter/lib/plugins/database_view/application/layout/calendar_setting_listener.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'dart:typed_data'; - -import 'package:appflowy/core/notification/grid_notification.dart'; -import 'package:flowy_infra/notifier.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; -import 'package:dartz/dartz.dart'; - -typedef NewLayoutFieldValue = Either; - -class DatabaseCalendarLayoutListener { - final String viewId; - PublishNotifier? _newLayoutFieldNotifier = - PublishNotifier(); - DatabaseNotificationListener? _listener; - DatabaseCalendarLayoutListener(this.viewId); - - void start({ - required void Function(NewLayoutFieldValue) onCalendarLayoutChanged, - }) { - _newLayoutFieldNotifier?.addPublishListener(onCalendarLayoutChanged); - _listener = DatabaseNotificationListener( - objectId: viewId, - handler: _handler, - ); - } - - void _handler( - DatabaseNotification ty, - Either result, - ) { - switch (ty) { - case DatabaseNotification.DidSetNewLayoutField: - result.fold( - (payload) => _newLayoutFieldNotifier?.value = - left(DatabaseLayoutSettingPB.fromBuffer(payload)), - (error) => _newLayoutFieldNotifier?.value = right(error), - ); - break; - default: - break; - } - } - - Future stop() async { - await _listener?.stop(); - _newLayoutFieldNotifier?.dispose(); - _newLayoutFieldNotifier = null; - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/application/row/row_list.dart b/frontend/appflowy_flutter/lib/plugins/database_view/application/row/row_list.dart index 4fd489dd985f..47663f0a44c3 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/application/row/row_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/application/row/row_list.dart @@ -100,7 +100,7 @@ class RowList { final List newRows = []; final DeletedIndexs deletedIndex = []; final Map deletedRowByRowId = { - for (var rowId in rowIds) rowId: rowId + for (final rowId in rowIds) rowId: rowId, }; _rowInfos.asMap().forEach((index, RowInfo rowInfo) { diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/application/row/row_service.dart b/frontend/appflowy_flutter/lib/plugins/database_view/application/row/row_service.dart index 8668341f7863..aa63d30747ff 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/application/row/row_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/application/row/row_service.dart @@ -40,6 +40,7 @@ class RowBackendService { required String rowId, String? iconURL, String? coverURL, + bool? isDocumentEmpty, }) { final payload = UpdateRowMetaChangesetPB.create() ..viewId = viewId @@ -52,6 +53,10 @@ class RowBackendService { payload.coverUrl = coverURL; } + if (isDocumentEmpty != null) { + payload.isDocumentEmpty = isDocumentEmpty; + } + return DatabaseEventUpdateRowMeta(payload).send(); } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/application/setting/group_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_view/application/setting/group_bloc.dart index 22504f6562e3..3e6bacba5953 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/application/setting/group_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/application/setting/group_bloc.dart @@ -1,6 +1,7 @@ import 'package:appflowy/plugins/database_view/application/database_controller.dart'; import 'package:appflowy/plugins/database_view/application/field/field_info.dart'; import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/board_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -14,7 +15,7 @@ class DatabaseGroupBloc extends Bloc { final DatabaseController _databaseController; final GroupBackendService _groupBackendSvc; Function(List)? _onFieldsFn; - GroupCallbacks? _groupCallbacks; + DatabaseLayoutSettingCallbacks? _layoutSettingCallbacks; DatabaseGroupBloc({ required String viewId, @@ -25,13 +26,13 @@ class DatabaseGroupBloc extends Bloc { DatabaseGroupState.initial( viewId, databaseController.fieldController.fieldInfos, + databaseController.databaseLayoutSetting!.board, ), ) { on( (event, emit) async { event.when( initial: () { - _loadGroupConfigurations(); _startListening(); }, didReceiveFieldUpdate: (fieldInfos) { @@ -43,8 +44,8 @@ class DatabaseGroupBloc extends Bloc { ); result.fold((l) => null, (err) => Log.error(err)); }, - didUpdateHideUngrouped: (bool hideUngrouped) { - emit(state.copyWith(hideUngrouped: hideUngrouped)); + didUpdateLayoutSettings: (layoutSettings) { + emit(state.copyWith(layoutSettings: layoutSettings)); }, ); }, @@ -58,7 +59,7 @@ class DatabaseGroupBloc extends Bloc { .removeListener(onFieldsListener: _onFieldsFn!); _onFieldsFn = null; } - _groupCallbacks = null; + _layoutSettingCallbacks = null; return super.close(); } @@ -70,32 +71,18 @@ class DatabaseGroupBloc extends Bloc { listenWhen: () => !isClosed, ); - _groupCallbacks = GroupCallbacks( - onGroupConfigurationChanged: (configurations) { - if (isClosed) { + _layoutSettingCallbacks = DatabaseLayoutSettingCallbacks( + onLayoutSettingsChanged: (layoutSettings) { + if (isClosed || !layoutSettings.hasBoard()) { return; } - final configuration = configurations.first; add( - DatabaseGroupEvent.didUpdateHideUngrouped( - configuration.hideUngrouped, - ), + DatabaseGroupEvent.didUpdateLayoutSettings(layoutSettings.board), ); }, ); - _databaseController.addListener(onGroupChanged: _groupCallbacks); - } - - void _loadGroupConfigurations() async { - final configResult = await _databaseController.loadGroupConfigurations( - viewId: _databaseController.viewId, - ); - configResult.fold( - (configurations) { - final hideUngrouped = configurations.first.hideUngrouped; - add(DatabaseGroupEvent.didUpdateHideUngrouped(hideUngrouped)); - }, - (err) => Log.error(err), + _databaseController.addListener( + onLayoutSettingsChanged: _layoutSettingCallbacks, ); } } @@ -110,8 +97,9 @@ class DatabaseGroupEvent with _$DatabaseGroupEvent { const factory DatabaseGroupEvent.didReceiveFieldUpdate( List fields, ) = _DidReceiveFieldUpdate; - const factory DatabaseGroupEvent.didUpdateHideUngrouped(bool hideUngrouped) = - _DidUpdateHideUngrouped; + const factory DatabaseGroupEvent.didUpdateLayoutSettings( + BoardLayoutSettingPB layoutSettings, + ) = _DidUpdateLayoutSettings; } @freezed @@ -119,16 +107,17 @@ class DatabaseGroupState with _$DatabaseGroupState { const factory DatabaseGroupState({ required String viewId, required List fieldInfos, - required bool hideUngrouped, + required BoardLayoutSettingPB layoutSettings, }) = _DatabaseGroupState; factory DatabaseGroupState.initial( String viewId, List fieldInfos, + BoardLayoutSettingPB layoutSettings, ) => DatabaseGroupState( viewId: viewId, fieldInfos: fieldInfos, - hideUngrouped: true, + layoutSettings: layoutSettings, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/application/tar_bar_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_view/application/tab_bar_bloc.dart similarity index 77% rename from frontend/appflowy_flutter/lib/plugins/database_view/application/tar_bar_bloc.dart rename to frontend/appflowy_flutter/lib/plugins/database_view/application/tab_bar_bloc.dart index 4b4697add2f2..91bf95f9a64d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/application/tar_bar_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/application/tab_bar_bloc.dart @@ -1,5 +1,5 @@ -import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_view.dart'; -import 'package:appflowy/plugins/database_view/tar_bar/tar_bar_add_button.dart'; +import 'package:appflowy/plugins/database_view/tab_bar/tab_bar_add_button.dart'; +import 'package:appflowy/plugins/database_view/tab_bar/tab_bar_view.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/log.dart'; @@ -11,14 +11,15 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'database_controller.dart'; import 'database_view_service.dart'; -part 'tar_bar_bloc.freezed.dart'; +part 'tab_bar_bloc.freezed.dart'; -class GridTabBarBloc extends Bloc { - GridTabBarBloc({ +class DatabaseTabBarBloc + extends Bloc { + DatabaseTabBarBloc({ bool isInlineView = false, required ViewPB view, - }) : super(GridTabBarState.initial(view)) { - on( + }) : super(DatabaseTabBarState.initial(view)) { + on( (event, emit) async { event.when( initial: () { @@ -31,7 +32,7 @@ class GridTabBarBloc extends Bloc { tabBars: [ ...state.tabBars, ...childViews.map( - (newChildView) => TarBar(view: newChildView), + (newChildView) => DatabaseTabBar(view: newChildView), ), ], tabBarControllerByViewId: _extendsTabBarController(childViews), @@ -64,7 +65,8 @@ class GridTabBarBloc extends Bloc { if (updatePB.createChildViews.isNotEmpty) { final allTabBars = [ ...state.tabBars, - ...updatePB.createChildViews.map((e) => TarBar(view: e)) + ...updatePB.createChildViews + .map((e) => DatabaseTabBar(view: e)), ]; emit( state.copyWith( @@ -79,7 +81,7 @@ class GridTabBarBloc extends Bloc { if (updatePB.deleteChildViews.isNotEmpty) { final allTabBars = [...state.tabBars]; final tabBarControllerByViewId = { - ...state.tabBarControllerByViewId + ...state.tabBarControllerByViewId, }; var newSelectedIndex = state.selectedIndex; for (final viewId in updatePB.deleteChildViews) { @@ -115,7 +117,7 @@ class GridTabBarBloc extends Bloc { ); if (index != -1) { final allTabBars = [...state.tabBars]; - final updatedTabBar = TarBar(view: updatedView); + final updatedTabBar = DatabaseTabBar(view: updatedView); allTabBars[index] = updatedTabBar; emit(state.copyWith(tabBars: allTabBars)); } @@ -136,24 +138,24 @@ class GridTabBarBloc extends Bloc { void _listenInlineViewChanged() { final controller = state.tabBarControllerByViewId[state.parentView.id]; controller?.onViewUpdated = (newView) { - add(GridTabBarEvent.viewDidUpdate(newView)); + add(DatabaseTabBarEvent.viewDidUpdate(newView)); }; // Only listen the child view changes when the parent view is inline. controller?.onViewChildViewChanged = (update) { - add(GridTabBarEvent.didUpdateChildViews(update)); + add(DatabaseTabBarEvent.didUpdateChildViews(update)); }; } /// Create tab bar controllers for the new views and return the updated map. - Map _extendsTabBarController( + Map _extendsTabBarController( List newViews, ) { final tabBarControllerByViewId = {...state.tabBarControllerByViewId}; for (final view in newViews) { - final controller = DatabaseTarBarController(view: view); + final controller = DatabaseTabBarController(view: view); controller.onViewUpdated = (newView) { - add(GridTabBarEvent.viewDidUpdate(newView)); + add(DatabaseTabBarEvent.viewDidUpdate(newView)); }; tabBarControllerByViewId[view.id] = controller; @@ -191,7 +193,7 @@ class GridTabBarBloc extends Bloc { return; } viewsOrFail.fold( - (views) => add(GridTabBarEvent.didLoadChildViews(views)), + (views) => add(DatabaseTabBarEvent.didLoadChildViews(views)), (err) => Log.error(err), ); }); @@ -199,48 +201,48 @@ class GridTabBarBloc extends Bloc { } @freezed -class GridTabBarEvent with _$GridTabBarEvent { - const factory GridTabBarEvent.initial() = _Initial; - const factory GridTabBarEvent.didLoadChildViews( +class DatabaseTabBarEvent with _$DatabaseTabBarEvent { + const factory DatabaseTabBarEvent.initial() = _Initial; + const factory DatabaseTabBarEvent.didLoadChildViews( List childViews, ) = _DidLoadChildViews; - const factory GridTabBarEvent.selectView(String viewId) = _DidSelectView; - const factory GridTabBarEvent.createView(AddButtonAction action) = + const factory DatabaseTabBarEvent.selectView(String viewId) = _DidSelectView; + const factory DatabaseTabBarEvent.createView(AddButtonAction action) = _CreateView; - const factory GridTabBarEvent.renameView(String viewId, String newName) = + const factory DatabaseTabBarEvent.renameView(String viewId, String newName) = _RenameView; - const factory GridTabBarEvent.deleteView(String viewId) = _DeleteView; - const factory GridTabBarEvent.didUpdateChildViews( + const factory DatabaseTabBarEvent.deleteView(String viewId) = _DeleteView; + const factory DatabaseTabBarEvent.didUpdateChildViews( ChildViewUpdatePB updatePB, ) = _DidUpdateChildViews; - const factory GridTabBarEvent.viewDidUpdate(ViewPB view) = _ViewDidUpdate; + const factory DatabaseTabBarEvent.viewDidUpdate(ViewPB view) = _ViewDidUpdate; } @freezed -class GridTabBarState with _$GridTabBarState { - const factory GridTabBarState({ +class DatabaseTabBarState with _$DatabaseTabBarState { + const factory DatabaseTabBarState({ required ViewPB parentView, required int selectedIndex, - required List tabBars, - required Map tabBarControllerByViewId, - }) = _GridTabBarState; + required List tabBars, + required Map tabBarControllerByViewId, + }) = _DatabaseTabBarState; - factory GridTabBarState.initial(ViewPB view) { - final tabBar = TarBar(view: view); - return GridTabBarState( + factory DatabaseTabBarState.initial(ViewPB view) { + final tabBar = DatabaseTabBar(view: view); + return DatabaseTabBarState( parentView: view, selectedIndex: 0, tabBars: [tabBar], tabBarControllerByViewId: { - view.id: DatabaseTarBarController( + view.id: DatabaseTabBarController( view: view, - ) + ), }, ); } } -class TarBar extends Equatable { +class DatabaseTabBar extends Equatable { final ViewPB view; final DatabaseTabBarItemBuilder _builder; @@ -248,7 +250,7 @@ class TarBar extends Equatable { DatabaseTabBarItemBuilder get builder => _builder; ViewLayoutPB get layout => view.layout; - TarBar({ + DatabaseTabBar({ required this.view, }) : _builder = view.tarBarItem(); @@ -261,14 +263,14 @@ typedef OnViewChildViewChanged = void Function( ChildViewUpdatePB childViewUpdate, ); -class DatabaseTarBarController { +class DatabaseTabBarController { ViewPB view; final DatabaseController controller; final ViewListener viewListener; OnViewUpdated? onViewUpdated; OnViewChildViewChanged? onViewChildViewChanged; - DatabaseTarBarController({ + DatabaseTabBarController({ required this.view, }) : controller = DatabaseController(view: view), viewListener = ViewListener(viewId: view.id) { diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/board/application/board_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_view/board/application/board_bloc.dart index b398dec9e889..52cb0e7e77cc 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/board/application/board_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/board/application/board_bloc.dart @@ -6,9 +6,7 @@ import 'package:appflowy/plugins/database_view/application/field/field_info.dart import 'package:appflowy/plugins/database_view/application/group/group_service.dart'; import 'package:appflowy/plugins/database_view/application/row/row_service.dart'; import 'package:appflowy_board/appflowy_board.dart'; -import 'package:collection/collection.dart'; import 'package:dartz/dartz.dart'; -import 'package:equatable/equatable.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; @@ -16,6 +14,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart' hide FieldInfo; import '../../application/field/field_controller.dart'; import '../../application/row/row_cache.dart'; @@ -25,12 +24,13 @@ import 'group_controller.dart'; part 'board_bloc.freezed.dart'; class BoardBloc extends Bloc { - late final GroupBackendService groupBackendSvc; final DatabaseController databaseController; - late final AppFlowyBoardController boardController; final LinkedHashMap groupControllers = LinkedHashMap(); - GroupPB? ungroupedGroup; + final List groupList = []; + + late final GroupBackendService groupBackendSvc; + late final AppFlowyBoardController boardController; FieldController get fieldController => databaseController.fieldController; String get viewId => databaseController.viewId; @@ -84,17 +84,20 @@ class BoardBloc extends Bloc { groupId: groupId, startRowId: startRowId, ); - result.fold( - (_) {}, - (err) => Log.error(err), - ); + + result.fold((_) {}, (err) => Log.error(err)); }, createHeaderRow: (String groupId) async { - final result = await databaseController.createRow(groupId: groupId); - result.fold( - (_) {}, - (err) => Log.error(err), + final result = await databaseController.createRow( + groupId: groupId, + fromBeginning: true, ); + + result.fold((_) {}, (err) => Log.error(err)); + }, + createGroup: (name) async { + final result = await groupBackendSvc.createGroup(name: name); + result.fold((_) {}, (err) => Log.error(err)); }, didCreateRow: (group, row, int? index) { emit( @@ -109,9 +112,52 @@ class BoardBloc extends Bloc { ); _groupItemStartEditing(group, row, true); }, + didReceiveGridUpdate: (DatabasePB grid) { + emit(state.copyWith(grid: Some(grid))); + }, + didReceiveError: (FlowyError error) { + emit(state.copyWith(noneOrError: some(error))); + }, + didReceiveGroups: (List groups) { + final hiddenGroups = _filterHiddenGroups(hideUngrouped, groups); + emit( + state.copyWith( + hiddenGroups: hiddenGroups, + groupIds: groups.map((group) => group.groupId).toList(), + ), + ); + }, + didUpdateLayoutSettings: (layoutSettings) { + final hiddenGroups = _filterHiddenGroups(hideUngrouped, groupList); + emit( + state.copyWith( + layoutSettings: layoutSettings, + hiddenGroups: hiddenGroups, + ), + ); + }, + toggleGroupVisibility: (GroupPB group, bool isVisible) async { + await _toggleGroupVisibility(group, isVisible); + }, + toggleHiddenSectionVisibility: (isVisible) async { + final newLayoutSettings = state.layoutSettings!; + newLayoutSettings.freeze(); + + final newLayoutSetting = newLayoutSettings.rebuild( + (message) => message.collapseHiddenGroups = isVisible, + ); + + await databaseController.updateLayoutSetting( + boardLayoutSetting: newLayoutSetting, + ); + }, + reorderGroup: (fromGroupId, toGroupId) async { + _reorderGroup(fromGroupId, toGroupId, emit); + }, startEditingRow: (group, row) { emit( state.copyWith( + isEditingRow: true, editingRow: BoardEditingRow( group: group, row: row, @@ -130,25 +176,9 @@ class BoardBloc extends Bloc { false, ); - emit(state.copyWith(isEditingRow: false)); + emit(state.copyWith(isEditingRow: false, editingRow: null)); } }, - didReceiveGridUpdate: (DatabasePB grid) { - emit(state.copyWith(grid: Some(grid))); - }, - didReceiveError: (FlowyError error) { - emit(state.copyWith(noneOrError: some(error))); - }, - didReceiveGroups: (List groups) { - emit( - state.copyWith( - groupIds: groups.map((group) => group.groupId).toList(), - ), - ); - }, - didUpdateHideUngrouped: (bool hideUngrouped) { - emit(state.copyWith(hideUngrouped: hideUngrouped)); - }, startEditingHeader: (String groupId) { emit( state.copyWith(isEditingHeader: true, editingHeaderId: groupId), @@ -160,7 +190,6 @@ class BoardBloc extends Bloc { groupId: groupId, name: groupName, ); - emit(state.copyWith(isEditingHeader: false)); }, ); @@ -171,13 +200,50 @@ class BoardBloc extends Bloc { void _groupItemStartEditing(GroupPB group, RowMetaPB row, bool isEdit) { final fieldInfo = fieldController.getField(group.fieldId); if (fieldInfo == null) { - Log.warn("fieldInfo should not be null"); - return; + return Log.warn("fieldInfo should not be null"); } boardController.enableGroupDragging(!isEdit); } + Future _toggleGroupVisibility(GroupPB group, bool isVisible) async { + if (group.isDefault) { + final newLayoutSettings = state.layoutSettings!; + newLayoutSettings.freeze(); + + final newLayoutSetting = newLayoutSettings.rebuild( + (message) => message.hideUngroupedColumn = !isVisible, + ); + + return databaseController.updateLayoutSetting( + boardLayoutSetting: newLayoutSetting, + ); + } + + await groupBackendSvc.updateGroup( + fieldId: groupControllers.values.first.group.fieldId, + groupId: group.groupId, + visible: isVisible, + ); + } + + Future _reorderGroup( + String fromGroupId, + String toGroupId, + Emitter emit, + ) async { + final fromIndex = groupList.indexWhere((g) => g.groupId == fromGroupId); + final toIndex = groupList.indexWhere((g) => g.groupId == toGroupId); + final group = groupList.removeAt(fromIndex); + groupList.insert(toIndex, group); + add(BoardEvent.didReceiveGroups(groupList)); + final result = await databaseController.moveGroup( + fromGroupId: fromGroupId, + toGroupId: toGroupId, + ); + result.fold((l) => {}, (err) => Log.error(err)); + } + @override Future close() async { for (final controller in groupControllers.values) { @@ -186,40 +252,45 @@ class BoardBloc extends Bloc { return super.close(); } + bool get hideUngrouped => + databaseController.databaseLayoutSetting?.board.hideUngroupedColumn ?? + false; + + FieldType get groupingFieldType { + final fieldInfo = + databaseController.fieldController.getField(groupList.first.fieldId)!; + + return fieldInfo.fieldType; + } + void initializeGroups(List groups) { for (final controller in groupControllers.values) { controller.dispose(); } + groupControllers.clear(); boardController.clear(); - - final ungroupedGroupIndex = - groups.indexWhere((group) => group.groupId == group.fieldId); - - if (ungroupedGroupIndex != -1) { - ungroupedGroup = groups[ungroupedGroupIndex]; - final group = groups.removeAt(ungroupedGroupIndex); - if (!state.hideUngrouped) { - groups.add(group); - } - } + groupList.clear(); + groupList.addAll(groups); boardController.addGroups( groups - .where((group) => fieldController.getField(group.fieldId) != null) - .map((group) => initializeGroupData(group)) + .where( + (group) => + fieldController.getField(group.fieldId) != null && + (group.isVisible || (group.isDefault && !hideUngrouped)), + ) + .map((group) => _initializeGroupData(group)) .toList(), ); for (final group in groups) { - final controller = initializeGroupController(group); - groupControllers[controller.group.groupId] = (controller); + final controller = _initializeGroupController(group); + groupControllers[controller.group.groupId] = controller; } } - RowCache? getRowCache() { - return databaseController.rowCache; - } + RowCache? getRowCache() => databaseController.rowCache; void _startListening() { final onDatabaseChanged = DatabaseCallbacks( @@ -229,50 +300,103 @@ class BoardBloc extends Bloc { } }, ); - final onGroupChanged = GroupCallbacks( - onGroupConfigurationChanged: (configurations) { - if (isClosed) return; - final config = configurations.first; - if (config.hideUngrouped) { - boardController.removeGroup(config.fieldId); - } else if (ungroupedGroup != null) { - final newGroup = initializeGroupData(ungroupedGroup!); - final controller = initializeGroupController(ungroupedGroup!); - groupControllers[controller.group.groupId] = (controller); - boardController.addGroup(newGroup); + final onLayoutSettingsChanged = DatabaseLayoutSettingCallbacks( + onLayoutSettingsChanged: (layoutSettings) { + if (isClosed) { + return; } - add(BoardEvent.didUpdateHideUngrouped(config.hideUngrouped)); + final index = groupList.indexWhere((element) => element.isDefault); + if (index != -1) { + if (layoutSettings.board.hideUngroupedColumn) { + boardController.removeGroup(groupList[index].fieldId); + } else { + final newGroup = _initializeGroupData(groupList[index]); + final visibleGroups = [...groupList] + ..retainWhere((g) => g.isVisible || g.isDefault); + final indexInVisibleGroups = + visibleGroups.indexWhere((g) => g.isDefault); + if (indexInVisibleGroups != -1) { + boardController.insertGroup(indexInVisibleGroups, newGroup); + } + } + } + add(BoardEvent.didUpdateLayoutSettings(layoutSettings.board)); }, + ); + final onGroupChanged = GroupCallbacks( onGroupByField: (groups) { - if (isClosed) return; - ungroupedGroup = null; + if (isClosed) { + return; + } + initializeGroups(groups); add(BoardEvent.didReceiveGroups(groups)); }, onDeleteGroup: (groupIds) { - if (isClosed) return; + if (isClosed) { + return; + } + boardController.removeGroups(groupIds); + groupList.removeWhere((group) => groupIds.contains(group.groupId)); + add(BoardEvent.didReceiveGroups(groupList)); }, onInsertGroup: (insertGroups) { - if (isClosed) return; + if (isClosed) { + return; + } + final group = insertGroups.group; - final newGroup = initializeGroupData(group); - final controller = initializeGroupController(group); - groupControllers[controller.group.groupId] = (controller); + final newGroup = _initializeGroupData(group); + final controller = _initializeGroupController(group); + groupControllers[controller.group.groupId] = controller; boardController.addGroup(newGroup); + groupList.insert(insertGroups.index, group); + add(BoardEvent.didReceiveGroups(groupList)); }, onUpdateGroup: (updatedGroups) { - if (isClosed) return; + if (isClosed) { + return; + } + for (final group in updatedGroups) { + // see if the column is already in the board + + final index = groupList.indexWhere((g) => g.groupId == group.groupId); + if (index == -1) continue; final columnController = boardController.getGroupController(group.groupId); - columnController?.updateGroupName(group.groupName); + if (columnController != null) { + // remove the group or update its name + columnController.updateGroupName(group.groupName); + if (!group.isVisible) { + boardController.removeGroup(group.groupId); + } + } else { + final newGroup = _initializeGroupData(group); + final visibleGroups = [...groupList]..retainWhere( + (g) => + g.isVisible || + g.isDefault && !hideUngrouped || + g.groupId == group.groupId, + ); + final indexInVisibleGroups = + visibleGroups.indexWhere((g) => g.groupId == group.groupId); + if (indexInVisibleGroups != -1) { + boardController.insertGroup(indexInVisibleGroups, newGroup); + } + } + + groupList.removeAt(index); + groupList.insert(index, group); } + add(BoardEvent.didReceiveGroups(groupList)); }, ); databaseController.addListener( onDatabaseChanged: onDatabaseChanged, + onLayoutSettingsChanged: onLayoutSettingsChanged, onGroupChanged: onGroupChanged, ); } @@ -304,24 +428,35 @@ class BoardBloc extends Bloc { ); } - GroupController initializeGroupController(GroupPB group) { + GroupController _initializeGroupController(GroupPB group) { final delegate = GroupControllerDelegateImpl( controller: boardController, fieldController: fieldController, - onNewColumnItem: (groupId, row, index) { - add(BoardEvent.didCreateRow(group, row, index)); - }, + onNewColumnItem: (groupId, row, index) => + add(BoardEvent.didCreateRow(group, row, index)), ); + final controller = GroupController( viewId: state.viewId, group: group, delegate: delegate, + onGroupChanged: (newGroup) { + if (isClosed) return; + + final index = + groupList.indexWhere((g) => g.groupId == newGroup.groupId); + if (index != -1) { + groupList.removeAt(index); + groupList.insert(index, newGroup); + add(BoardEvent.didReceiveGroups(groupList)); + } + }, ); - controller.startListening(); - return controller; + + return controller..startListening(); } - AppFlowyGroupData initializeGroupData(GroupPB group) { + AppFlowyGroupData _initializeGroupData(GroupPB group) { return AppFlowyGroupData( id: group.groupId, name: group.groupName, @@ -339,6 +474,7 @@ class BoardEvent with _$BoardEvent { const factory BoardEvent.initial() = _InitialBoard; const factory BoardEvent.createBottomRow(String groupId) = _CreateBottomRow; const factory BoardEvent.createHeaderRow(String groupId) = _CreateHeaderRow; + const factory BoardEvent.createGroup(String name) = _CreateGroup; const factory BoardEvent.startEditingHeader(String groupId) = _StartEditingHeader; const factory BoardEvent.endEditingHeader(String groupId, String groupName) = @@ -353,14 +489,23 @@ class BoardEvent with _$BoardEvent { RowMetaPB row, ) = _StartEditRow; const factory BoardEvent.endEditingRow(RowId rowId) = _EndEditRow; + const factory BoardEvent.toggleGroupVisibility( + GroupPB group, + bool isVisible, + ) = _ToggleGroupVisibility; + const factory BoardEvent.toggleHiddenSectionVisibility(bool isVisible) = + _ToggleHiddenSectionVisibility; + const factory BoardEvent.reorderGroup(String fromGroupId, String toGroupId) = + _ReorderGroup; const factory BoardEvent.didReceiveError(FlowyError error) = _DidReceiveError; const factory BoardEvent.didReceiveGridUpdate( DatabasePB grid, ) = _DidReceiveGridUpdate; const factory BoardEvent.didReceiveGroups(List groups) = _DidReceiveGroups; - const factory BoardEvent.didUpdateHideUngrouped(bool hideUngrouped) = - _DidUpdateHideUngrouped; + const factory BoardEvent.didUpdateLayoutSettings( + BoardLayoutSettingPB layoutSettings, + ) = _DidUpdateLayoutSettings; } @freezed @@ -370,12 +515,13 @@ class BoardState with _$BoardState { required Option grid, required List groupIds, required bool isEditingHeader, - String? editingHeaderId, required bool isEditingRow, - BoardEditingRow? editingRow, required LoadingState loadingState, required Option noneOrError, - required bool hideUngrouped, + required BoardLayoutSettingPB? layoutSettings, + String? editingHeaderId, + BoardEditingRow? editingRow, + required List hiddenGroups, }) = _BoardState; factory BoardState.initial(String viewId) => BoardState( @@ -386,31 +532,15 @@ class BoardState with _$BoardState { isEditingRow: false, noneOrError: none(), loadingState: const LoadingState.loading(), - hideUngrouped: false, + layoutSettings: null, + hiddenGroups: [], ); } -class GridFieldEquatable extends Equatable { - final UnmodifiableListView _fields; - const GridFieldEquatable( - UnmodifiableListView fields, - ) : _fields = fields; - - @override - List get props { - if (_fields.isEmpty) { - return []; - } - - return [ - _fields.length, - _fields - .map((field) => field.width) - .reduce((value, element) => value + element), - ]; - } - - UnmodifiableListView get value => UnmodifiableListView(_fields); +List _filterHiddenGroups(bool hideUngrouped, List groups) { + return [...groups]..retainWhere( + (group) => !group.isVisible || group.isDefault && hideUngrouped, + ); } class GroupItem extends AppFlowyGroupItem { @@ -440,12 +570,16 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate { required this.onNewColumnItem, }); + @override + bool hasGroup(String groupId) { + return controller.groupIds.contains(groupId); + } + @override void insertRow(GroupPB group, RowMetaPB row, int? index) { final fieldInfo = fieldController.getField(group.fieldId); if (fieldInfo == null) { - Log.warn("fieldInfo should not be null"); - return; + return Log.warn("fieldInfo should not be null"); } if (index != null) { @@ -464,17 +598,16 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate { } @override - void removeRow(GroupPB group, RowId rowId) { - controller.removeGroupItem(group.groupId, rowId.toString()); - } + void removeRow(GroupPB group, RowId rowId) => + controller.removeGroupItem(group.groupId, rowId.toString()); @override void updateRow(GroupPB group, RowMetaPB row) { final fieldInfo = fieldController.getField(group.fieldId); if (fieldInfo == null) { - Log.warn("fieldInfo should not be null"); - return; + return Log.warn("fieldInfo should not be null"); } + controller.updateGroupItem( group.groupId, GroupItem( @@ -488,20 +621,17 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate { void addNewRow(GroupPB group, RowMetaPB row, int? index) { final fieldInfo = fieldController.getField(group.fieldId); if (fieldInfo == null) { - Log.warn("fieldInfo should not be null"); - return; + return Log.warn("fieldInfo should not be null"); } - final item = GroupItem( - row: row, - fieldInfo: fieldInfo, - draggable: false, - ); + + final item = GroupItem(row: row, fieldInfo: fieldInfo, draggable: false); if (index != null) { controller.insertGroupItem(group.groupId, index, item); } else { controller.addGroupItem(group.groupId, item); } + onNewColumnItem(group.groupId, row, index); } } @@ -519,27 +649,26 @@ class BoardEditingRow { } class GroupData { - final GroupPB group; - final FieldInfo fieldInfo; GroupData({ required this.group, required this.fieldInfo, }); - CheckboxGroup? asCheckboxGroup() { - if (fieldType != FieldType.Checkbox) return null; - return CheckboxGroup(group); - } + final GroupPB group; + final FieldInfo fieldInfo; + + CheckboxGroup? asCheckboxGroup() => + fieldType == FieldType.Checkbox ? CheckboxGroup(group) : null; FieldType get fieldType => fieldInfo.fieldType; } class CheckboxGroup { - final GroupPB group; + const CheckboxGroup(this.group); - CheckboxGroup(this.group); + final GroupPB group; -// Hardcode value: "Yes" that equal to the value defined in Rust -// pub const CHECK: &str = "Yes"; + // Hardcode value: "Yes" that equal to the value defined in Rust + // pub const CHECK: &str = "Yes"; bool get isCheck => group.groupId == "Yes"; } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/board/application/group.dart b/frontend/appflowy_flutter/lib/plugins/database_view/board/application/group.dart deleted file mode 100644 index feefa34db70f..000000000000 --- a/frontend/appflowy_flutter/lib/plugins/database_view/board/application/group.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; - -class BoardGroupService { - final String viewId; - FieldPB? groupField; - - BoardGroupService(this.viewId); - - void setGroupField(FieldPB field) { - groupField = field; - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/board/application/group_controller.dart b/frontend/appflowy_flutter/lib/plugins/database_view/board/application/group_controller.dart index 0e4a315de379..6f7f359992e2 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/board/application/group_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/board/application/group_controller.dart @@ -7,10 +7,12 @@ import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:flowy_infra/notifier.dart'; import 'package:dartz/dartz.dart'; +import 'package:protobuf/protobuf.dart'; typedef OnGroupError = void Function(FlowyError); abstract class GroupControllerDelegate { + bool hasGroup(String groupId); void removeRow(GroupPB group, RowId rowId); void insertRow(GroupPB group, RowMetaPB row, int? index); void updateRow(GroupPB group, RowMetaPB row); @@ -18,14 +20,16 @@ abstract class GroupControllerDelegate { } class GroupController { - final GroupPB group; + GroupPB group; final SingleGroupListener _listener; final GroupControllerDelegate delegate; + final void Function(GroupPB group) onGroupChanged; GroupController({ required String viewId, required this.group, required this.delegate, + required this.onGroupChanged, }) : _listener = SingleGroupListener(group); RowMetaPB? rowAtIndex(int index) { @@ -46,37 +50,52 @@ class GroupController { onGroupChanged: (result) { result.fold( (GroupRowsNotificationPB changeset) { + final newItems = [...group.rows]; + final isGroupExist = delegate.hasGroup(group.groupId); for (final deletedRow in changeset.deletedRows) { - group.rows.removeWhere((rowPB) => rowPB.id == deletedRow); - delegate.removeRow(group, deletedRow); + newItems.removeWhere((rowPB) => rowPB.id == deletedRow); + if (isGroupExist) { + delegate.removeRow(group, deletedRow); + } } for (final insertedRow in changeset.insertedRows) { final index = insertedRow.hasIndex() ? insertedRow.index : null; if (insertedRow.hasIndex() && - group.rows.length > insertedRow.index) { - group.rows.insert(insertedRow.index, insertedRow.rowMeta); + newItems.length > insertedRow.index) { + newItems.insert(insertedRow.index, insertedRow.rowMeta); } else { - group.rows.add(insertedRow.rowMeta); + newItems.add(insertedRow.rowMeta); } - if (insertedRow.isNew) { - delegate.addNewRow(group, insertedRow.rowMeta, index); - } else { - delegate.insertRow(group, insertedRow.rowMeta, index); + if (isGroupExist) { + if (insertedRow.isNew) { + delegate.addNewRow(group, insertedRow.rowMeta, index); + } else { + delegate.insertRow(group, insertedRow.rowMeta, index); + } } } for (final updatedRow in changeset.updatedRows) { - final index = group.rows.indexWhere( + final index = newItems.indexWhere( (rowPB) => rowPB.id == updatedRow.id, ); if (index != -1) { - group.rows[index] = updatedRow; - delegate.updateRow(group, updatedRow); + newItems[index] = updatedRow; + if (isGroupExist) { + delegate.updateRow(group, updatedRow); + } } } + + group.freeze(); + group = group.rebuild((group) { + group.rows.clear(); + group.rows.addAll(newItems); + }); + onGroupChanged(group); }, (err) => Log.error(err), ); diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/board/application/ungrouped_items_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_view/board/application/ungrouped_items_bloc.dart deleted file mode 100644 index b2510087a331..000000000000 --- a/frontend/appflowy_flutter/lib/plugins/database_view/board/application/ungrouped_items_bloc.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; -import 'package:bloc/bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -import 'group_controller.dart'; - -part 'ungrouped_items_bloc.freezed.dart'; - -class UngroupedItemsBloc - extends Bloc { - UngroupedItemsListener? listener; - - UngroupedItemsBloc({required GroupPB group}) - : super(UngroupedItemsState(ungroupedItems: group.rows)) { - on( - (event, emit) { - event.when( - initial: () { - listener = UngroupedItemsListener( - initialGroup: group, - onGroupChanged: (ungroupedItems) { - if (isClosed) return; - add( - UngroupedItemsEvent.updateGroup( - ungroupedItems: ungroupedItems, - ), - ); - }, - )..startListening(); - }, - updateGroup: (newItems) => - emit(UngroupedItemsState(ungroupedItems: newItems)), - ); - }, - ); - } -} - -@freezed -class UngroupedItemsEvent with _$UngroupedItemsEvent { - const factory UngroupedItemsEvent.initial() = _Initial; - const factory UngroupedItemsEvent.updateGroup({ - required List ungroupedItems, - }) = _UpdateGroup; -} - -@freezed -class UngroupedItemsState with _$UngroupedItemsState { - const factory UngroupedItemsState({ - required List ungroupedItems, - }) = _UngroupedItemsState; -} - -class UngroupedItemsListener { - List _ungroupedItems; - final SingleGroupListener _listener; - final void Function(List items) onGroupChanged; - - UngroupedItemsListener({ - required GroupPB initialGroup, - required this.onGroupChanged, - }) : _ungroupedItems = List.from(initialGroup.rows), - _listener = SingleGroupListener(initialGroup); - - void startListening() { - _listener.start( - onGroupChanged: (result) { - result.fold( - (GroupRowsNotificationPB changeset) { - final newItems = List.from(_ungroupedItems); - for (final deletedRow in changeset.deletedRows) { - newItems.removeWhere((rowPB) => rowPB.id == deletedRow); - } - - for (final insertedRow in changeset.insertedRows) { - final index = newItems.indexWhere( - (rowPB) => rowPB.id == insertedRow.rowMeta.id, - ); - if (index != -1) { - continue; - } - if (insertedRow.hasIndex() && - newItems.length > insertedRow.index) { - newItems.insert(insertedRow.index, insertedRow.rowMeta); - } else { - newItems.add(insertedRow.rowMeta); - } - } - - for (final updatedRow in changeset.updatedRows) { - final index = newItems.indexWhere( - (rowPB) => rowPB.id == updatedRow.id, - ); - - if (index != -1) { - newItems[index] = updatedRow; - } - } - onGroupChanged.call(newItems); - _ungroupedItems = newItems; - }, - (err) => Log.error(err), - ); - }, - ); - } - - Future dispose() async { - _listener.stop(); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/board/board.dart b/frontend/appflowy_flutter/lib/plugins/database_view/board/board.dart index 10a25d7e5273..17aa09b78893 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/board/board.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/board/board.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_view.dart'; +import 'package:appflowy/plugins/database_view/tab_bar/tab_bar_view.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/board/presentation/board_page.dart b/frontend/appflowy_flutter/lib/plugins/database_view/board/presentation/board_page.dart index 4a3673a0e875..fd9b497135e4 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/board/presentation/board_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/board/presentation/board_page.dart @@ -1,5 +1,3 @@ -// ignore_for_file: unused_field - import 'dart:collection'; import 'package:appflowy/generated/flowy_svgs.g.dart'; @@ -9,19 +7,19 @@ import 'package:appflowy/plugins/database_view/application/field/field_controlle import 'package:appflowy/plugins/database_view/application/row/row_cache.dart'; import 'package:appflowy/plugins/database_view/application/row/row_controller.dart'; import 'package:appflowy/plugins/database_view/board/presentation/widgets/board_column_header.dart'; -import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart'; -import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_view.dart'; +import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart'; +import 'package:appflowy/plugins/database_view/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/database_view/widgets/row/row_detail.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui_web.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart' hide Card; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../widgets/card/cells/card_cell.dart'; @@ -30,7 +28,7 @@ import '../../widgets/row/cell_builder.dart'; import '../application/board_bloc.dart'; import '../../widgets/card/card.dart'; import 'toolbar/board_setting_bar.dart'; -import 'ungrouped_items_button.dart'; +import 'widgets/board_hidden_groups.dart'; class BoardPageTabBarBuilderImpl implements DatabaseTabBarItemBuilder { @override @@ -39,46 +37,38 @@ class BoardPageTabBarBuilderImpl implements DatabaseTabBarItemBuilder { ViewPB view, DatabaseController controller, bool shrinkWrap, - ) { - return BoardPage( - key: _makeValueKey(controller), - view: view, - databaseController: controller, - ); - } + ) => + BoardPage(view: view, databaseController: controller); @override - Widget settingBar(BuildContext context, DatabaseController controller) { - return BoardSettingBar( - key: _makeValueKey(controller), - databaseController: controller, - ); - } + Widget settingBar(BuildContext context, DatabaseController controller) => + BoardSettingBar( + key: _makeValueKey(controller), + databaseController: controller, + ); @override Widget settingBarExtension( BuildContext context, DatabaseController controller, - ) { - return SizedBox.fromSize(); - } + ) => + const SizedBox.shrink(); - ValueKey _makeValueKey(DatabaseController controller) { - return ValueKey(controller.viewId); - } + ValueKey _makeValueKey(DatabaseController controller) => + ValueKey(controller.viewId); } class BoardPage extends StatelessWidget { - final DatabaseController databaseController; BoardPage({ required this.view, required this.databaseController, - Key? key, this.onEditStateChanged, }) : super(key: ValueKey(view.id)); final ViewPB view; + final DatabaseController databaseController; + /// Called when edit state changed final VoidCallback? onEditStateChanged; @@ -91,23 +81,18 @@ class BoardPage extends StatelessWidget { )..add(const BoardEvent.initial()), child: BlocBuilder( buildWhen: (p, c) => p.loadingState != c.loadingState, - builder: (context, state) { - return state.loadingState.map( - loading: (_) => - const Center(child: CircularProgressIndicator.adaptive()), - finish: (result) { - return result.successOrFail.fold( - (_) => BoardContent( - onEditStateChanged: onEditStateChanged, - ), - (err) => FlowyErrorPage.message( - err.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), - ), - ); - }, - ); - }, + builder: (context, state) => state.loadingState.map( + loading: (_) => const Center( + child: CircularProgressIndicator.adaptive(), + ), + finish: (result) => result.successOrFail.fold( + (_) => BoardContent(onEditStateChanged: onEditStateChanged), + (err) => FlowyErrorPage.message( + err.toString(), + howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + ), + ), + ), ), ); } @@ -115,9 +100,9 @@ class BoardPage extends StatelessWidget { class BoardContent extends StatefulWidget { const BoardContent({ - Key? key, + super.key, this.onEditStateChanged, - }) : super(key: key); + }); final VoidCallback? onEditStateChanged; @@ -126,11 +111,14 @@ class BoardContent extends StatefulWidget { } class _BoardContentState extends State { - late AppFlowyBoardScrollController scrollManager; final renderHook = RowCardRenderHook(); + late final ScrollController scrollController; + late final AppFlowyBoardScrollController scrollManager; final config = const AppFlowyBoardConfig( groupBackgroundColor: Color(0xffF7F8FC), + headerPadding: EdgeInsets.symmetric(horizontal: 8), + cardPadding: EdgeInsets.symmetric(horizontal: 4, vertical: 3), ); @override @@ -138,6 +126,7 @@ class _BoardContentState extends State { super.initState(); scrollManager = AppFlowyBoardScrollController(); + scrollController = ScrollController(); renderHook.addSelectOptionHook((options, groupId, _) { // The cell should hide if the option id is equal to the groupId. final isInGroup = @@ -160,41 +149,36 @@ class _BoardContentState extends State { }, child: BlocBuilder( builder: (context, state) { + final showCreateGroupButton = + context.read().groupingFieldType.canCreateNewGroup; return Padding( - padding: GridSize.contentInsets, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - const VSpace(8.0), - if (state.hideUngrouped) _buildBoardHeader(context), - Expanded( - child: AppFlowyBoard( - boardScrollController: scrollManager, - scrollController: ScrollController(), - controller: context.read().boardController, - headerBuilder: (_, groupData) => - BlocProvider.value( - value: context.read(), - child: BoardColumnHeader( - groupData: groupData, - margin: config.headerPadding, - ), - ), - footerBuilder: _buildFooter, - cardBuilder: (_, column, columnItem) => _buildCard( - context, - column, - columnItem, - ), - groupConstraints: const BoxConstraints.tightFor(width: 300), - config: AppFlowyBoardConfig( - groupBackgroundColor: - Theme.of(context).colorScheme.surfaceVariant, - ), - ), - ) - ], + padding: const EdgeInsets.only(top: 8.0), + child: AppFlowyBoard( + boardScrollController: scrollManager, + scrollController: scrollController, + controller: context.read().boardController, + groupConstraints: const BoxConstraints.tightFor(width: 300), + config: const AppFlowyBoardConfig( + groupPadding: EdgeInsets.symmetric(horizontal: 4), + groupItemPadding: EdgeInsets.symmetric(horizontal: 4), + ), + leading: HiddenGroupsColumn(margin: config.headerPadding), + trailing: showCreateGroupButton + ? BoardTrailing(scrollController: scrollController) + : null, + headerBuilder: (_, groupData) => BlocProvider.value( + value: context.read(), + child: BoardColumnHeader( + groupData: groupData, + margin: config.headerPadding, + ), + ), + footerBuilder: _buildFooter, + cardBuilder: (_, column, columnItem) => _buildCard( + context, + column, + columnItem, + ), ), ); }, @@ -202,19 +186,6 @@ class _BoardContentState extends State { ); } - Widget _buildBoardHeader(BuildContext context) { - return const Padding( - padding: EdgeInsets.only(bottom: 8.0), - child: SizedBox( - height: 24, - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: UngroupedItemsButton(), - ), - ), - ); - } - void _handleEditStateChanged(BoardState state, BuildContext context) { if (state.isEditingRow && state.editingRow != null) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -227,25 +198,24 @@ class _BoardContentState extends State { Widget _buildFooter(BuildContext context, AppFlowyGroupData columnData) { return AppFlowyGroupFooter( + height: 50, + margin: config.footerPadding, icon: SizedBox( height: 20, width: 20, child: FlowySvg( FlowySvgs.add_s, - color: Theme.of(context).iconTheme.color, + color: Theme.of(context).hintColor, ), ), title: FlowyText.medium( LocaleKeys.board_column_createNewCard.tr(), + color: Theme.of(context).hintColor, fontSize: 14, ), - height: 50, - margin: config.footerPadding, - onAddButtonClick: () { - context.read().add( - BoardEvent.createBottomRow(columnData.id), - ); - }, + onAddButtonClick: () => context + .read() + .add(BoardEvent.createBottomRow(columnData.id)), ); } @@ -271,6 +241,7 @@ class _BoardContentState extends State { boardBloc.state.editingRow?.row.id == groupItem.row.id; final groupItemId = groupItem.row.id + groupData.group.groupId; + return AppFlowyGroupCard( key: ValueKey(groupItemId), margin: config.cardPadding, @@ -302,15 +273,27 @@ class _BoardContentState extends State { } BoxDecoration _makeBoxDecoration(BuildContext context) { - final borderSide = BorderSide( - color: Theme.of(context).dividerColor, - width: 1.0, - ); - final isLightMode = Theme.of(context).brightness == Brightness.light; return BoxDecoration( color: Theme.of(context).colorScheme.surface, - border: isLightMode ? Border.fromBorderSide(borderSide) : null, borderRadius: const BorderRadius.all(Radius.circular(6)), + border: Border.fromBorderSide( + BorderSide( + color: Theme.of(context).dividerColor, + width: 1.4, + ), + ), + boxShadow: [ + BoxShadow( + blurRadius: 4, + spreadRadius: 0, + color: const Color(0xFF1F2329).withOpacity(0.02), + ), + BoxShadow( + blurRadius: 4, + spreadRadius: -2, + color: const Color(0xFF1F2329).withOpacity(0.02), + ), + ], ); } @@ -338,12 +321,113 @@ class _BoardContentState extends State { FlowyOverlay.show( context: context, - builder: (BuildContext context) { - return RowDetailPage( - cellBuilder: GridCellBuilder(cellCache: dataController.cellCache), - rowController: dataController, - ); + builder: (_) => RowDetailPage( + cellBuilder: GridCellBuilder(cellCache: dataController.cellCache), + rowController: dataController, + ), + ); + } +} + +class BoardTrailing extends StatefulWidget { + const BoardTrailing({super.key, required this.scrollController}); + + final ScrollController scrollController; + + @override + State createState() => _BoardTrailingState(); +} + +class _BoardTrailingState extends State { + final TextEditingController _textController = TextEditingController(); + late final FocusNode _focusNode; + + bool isEditing = false; + + void _cancelAddNewGroup() { + _textController.clear(); + setState(() => isEditing = false); + } + + @override + void initState() { + super.initState(); + _focusNode = FocusNode( + onKeyEvent: (node, event) { + if (_focusNode.hasFocus && + event.logicalKey == LogicalKeyboardKey.escape) { + _cancelAddNewGroup(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; }, + )..addListener(() { + if (!_focusNode.hasFocus) { + _cancelAddNewGroup(); + } + }); + } + + @override + Widget build(BuildContext context) { + // call after every setState + WidgetsBinding.instance.addPostFrameCallback((_) { + if (isEditing) { + _focusNode.requestFocus(); + widget.scrollController.jumpTo( + widget.scrollController.position.maxScrollExtent, + ); + } + }); + + return Padding( + padding: const EdgeInsets.only(left: 8.0, top: 12), + child: Align( + alignment: AlignmentDirectional.topStart, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: isEditing + ? SizedBox( + width: 256, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: _textController, + focusNode: _focusNode, + decoration: InputDecoration( + suffixIcon: Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8.0), + child: FlowyIconButton( + icon: const FlowySvg(FlowySvgs.close_filled_m), + hoverColor: Colors.transparent, + onPressed: () => _textController.clear(), + ), + ), + suffixIconConstraints: + BoxConstraints.loose(const Size(20, 24)), + border: const UnderlineInputBorder(), + contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 8), + isDense: true, + ), + style: Theme.of(context).textTheme.bodySmall, + maxLines: 1, + onSubmitted: (groupName) => context + .read() + .add(BoardEvent.createGroup(groupName)), + ), + ), + ) + : FlowyTooltip( + message: LocaleKeys.board_column_createNewColumn.tr(), + child: FlowyIconButton( + width: 26, + icon: const FlowySvg(FlowySvgs.add_s), + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + onPressed: () => setState(() => isEditing = true), + ), + ), + ), + ), ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/board/presentation/ungrouped_items_button.dart b/frontend/appflowy_flutter/lib/plugins/database_view/board/presentation/ungrouped_items_button.dart deleted file mode 100644 index b77809b11ff4..000000000000 --- a/frontend/appflowy_flutter/lib/plugins/database_view/board/presentation/ungrouped_items_button.dart +++ /dev/null @@ -1,230 +0,0 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart'; -import 'package:appflowy/plugins/database_view/application/field/field_info.dart'; -import 'package:appflowy/plugins/database_view/application/row/row_cache.dart'; -import 'package:appflowy/plugins/database_view/application/row/row_controller.dart'; -import 'package:appflowy/plugins/database_view/board/application/board_bloc.dart'; -import 'package:appflowy/plugins/database_view/board/application/ungrouped_items_bloc.dart'; -import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart'; -import 'package:appflowy/plugins/database_view/widgets/card/card_cell_builder.dart'; -import 'package:appflowy/plugins/database_view/widgets/card/cells/card_cell.dart'; -import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart'; -import 'package:appflowy/plugins/database_view/widgets/row/cells/text_cell/text_cell_bloc.dart'; -import 'package:appflowy/plugins/database_view/widgets/row/row_detail.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:collection/collection.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class UngroupedItemsButton extends StatefulWidget { - const UngroupedItemsButton({super.key}); - - @override - State createState() => _UnscheduledEventsButtonState(); -} - -class _UnscheduledEventsButtonState extends State { - late final PopoverController _popoverController; - - @override - void initState() { - super.initState(); - _popoverController = PopoverController(); - } - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, boardState) { - final ungroupedGroup = context.watch().ungroupedGroup; - final databaseController = context.read().databaseController; - final primaryField = databaseController.fieldController.fieldInfos - .firstWhereOrNull((element) => element.isPrimary)!; - - if (ungroupedGroup == null) { - return const SizedBox.shrink(); - } - - return BlocProvider( - create: (_) => UngroupedItemsBloc(group: ungroupedGroup) - ..add(const UngroupedItemsEvent.initial()), - child: BlocBuilder( - builder: (context, state) { - return AppFlowyPopover( - direction: PopoverDirection.bottomWithCenterAligned, - triggerActions: PopoverTriggerFlags.none, - controller: _popoverController, - offset: const Offset(0, 8), - constraints: - const BoxConstraints(maxWidth: 282, maxHeight: 600), - child: OutlinedButton( - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder( - side: BorderSide( - color: Theme.of(context).dividerColor, - width: 1, - ), - borderRadius: Corners.s6Border, - ), - side: BorderSide( - color: Theme.of(context).dividerColor, - width: 1, - ), - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - visualDensity: VisualDensity.compact, - ), - onPressed: () { - if (state.ungroupedItems.isNotEmpty) { - _popoverController.show(); - } - }, - child: FlowyText.regular( - "${LocaleKeys.board_ungroupedButtonText.tr()} (${state.ungroupedItems.length})", - fontSize: 10, - ), - ), - popupBuilder: (context) { - return UngroupedItemList( - viewId: databaseController.viewId, - primaryField: primaryField, - rowCache: databaseController.rowCache, - ungroupedItems: state.ungroupedItems, - ); - }, - ); - }, - ), - ); - }, - ); - } -} - -class UngroupedItemList extends StatelessWidget { - final String viewId; - final FieldInfo primaryField; - final RowCache rowCache; - final List ungroupedItems; - const UngroupedItemList({ - required this.viewId, - required this.primaryField, - required this.ungroupedItems, - required this.rowCache, - super.key, - }); - - @override - Widget build(BuildContext context) { - final cells = [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: FlowyText.medium( - LocaleKeys.board_ungroupedItemsTitle.tr(), - fontSize: 10, - color: Theme.of(context).hintColor, - overflow: TextOverflow.ellipsis, - ), - ), - ...ungroupedItems.map( - (item) { - final rowController = RowController( - rowMeta: item, - viewId: viewId, - rowCache: rowCache, - ); - final renderHook = RowCardRenderHook(); - renderHook.addTextCellHook((cellData, _, __) { - return BlocBuilder( - builder: (context, state) { - final text = cellData.isEmpty - ? LocaleKeys.grid_row_titlePlaceholder.tr() - : cellData; - - if (text.isEmpty) { - return const SizedBox.shrink(); - } - - return Align( - alignment: Alignment.centerLeft, - child: FlowyText.medium( - text, - textAlign: TextAlign.left, - fontSize: 11, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ); - }, - ); - }); - return UngroupedItem( - cellContext: rowCache.loadCells(item)[primaryField.id]!, - primaryField: primaryField, - rowController: rowController, - cellBuilder: CardCellBuilder(rowController.cellCache), - renderHook: renderHook, - onPressed: () { - FlowyOverlay.show( - context: context, - builder: (BuildContext context) { - return RowDetailPage( - cellBuilder: - GridCellBuilder(cellCache: rowController.cellCache), - rowController: rowController, - ); - }, - ); - PopoverContainer.of(context).close(); - }, - ); - }, - ) - ]; - - return ListView.separated( - itemBuilder: (context, index) => cells[index], - itemCount: cells.length, - separatorBuilder: (context, index) => - VSpace(GridSize.typeOptionSeparatorHeight), - shrinkWrap: true, - ); - } -} - -class UngroupedItem extends StatelessWidget { - final DatabaseCellContext cellContext; - final FieldInfo primaryField; - final RowController rowController; - final CardCellBuilder cellBuilder; - final RowCardRenderHook renderHook; - final VoidCallback onPressed; - const UngroupedItem({ - super.key, - required this.cellContext, - required this.onPressed, - required this.cellBuilder, - required this.rowController, - required this.primaryField, - required this.renderHook, - }); - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 26, - child: FlowyButton( - margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - text: cellBuilder.buildCell( - cellContext: cellContext, - renderHook: renderHook, - ), - onTap: onPressed, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/board/presentation/widgets/board_column_header.dart b/frontend/appflowy_flutter/lib/plugins/database_view/board/presentation/widgets/board_column_header.dart index 60f624c05934..67c01a51a3f2 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/board/presentation/widgets/board_column_header.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/board/presentation/widgets/board_column_header.dart @@ -1,14 +1,15 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database_view/board/application/board_bloc.dart'; +import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart'; import 'package:appflowy/plugins/database_view/widgets/card/define.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart'; import 'package:appflowy_board/appflowy_board.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -18,11 +19,11 @@ class BoardColumnHeader extends StatefulWidget { const BoardColumnHeader({ super.key, required this.groupData, - this.margin, + required this.margin, }); final AppFlowyGroupData groupData; - final EdgeInsets? margin; + final EdgeInsets margin; @override State createState() => _BoardColumnHeaderState(); @@ -62,77 +63,77 @@ class _BoardColumnHeaderState extends State { Widget build(BuildContext context) { final boardCustomData = widget.groupData.customData as GroupData; - return BlocProvider.value( - value: context.read(), - child: BlocBuilder( - builder: (context, state) { - if (state.isEditingHeader) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNode.requestFocus(); - }); - } + return BlocBuilder( + builder: (context, state) { + if (state.isEditingHeader) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } - Widget title = Expanded( - child: FlowyText.medium( - widget.groupData.headerData.groupName, - fontSize: 14, - overflow: TextOverflow.clip, - ), - ); + Widget title = Expanded( + child: FlowyText.medium( + widget.groupData.headerData.groupName, + fontSize: 14, + overflow: TextOverflow.ellipsis, + ), + ); - if (!boardCustomData.group.isDefault && - boardCustomData.fieldType.canEditHeader) { - title = Flexible( - fit: FlexFit.tight, - child: FlowyTooltip( - message: LocaleKeys.board_column_renameGroupTooltip.tr(), - child: FlowyHover( - style: HoverStyle( - hoverColor: Colors.transparent, - foregroundColorOnHover: - AFThemeExtension.of(context).textColor, - ), - child: GestureDetector( - onTap: () => context.read().add( - BoardEvent.startEditingHeader( - widget.groupData.id, - ), - ), - child: FlowyText.medium( - widget.groupData.headerData.groupName, - fontSize: 14, - overflow: TextOverflow.clip, - ), + if (!boardCustomData.group.isDefault && + boardCustomData.fieldType.canEditHeader) { + title = Flexible( + fit: FlexFit.tight, + child: FlowyTooltip( + message: LocaleKeys.board_column_renameGroupTooltip.tr(), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => context + .read() + .add(BoardEvent.startEditingHeader(widget.groupData.id)), + child: FlowyText.medium( + widget.groupData.headerData.groupName, + fontSize: 14, + overflow: TextOverflow.ellipsis, ), ), ), - ); - } + ), + ); + } - if (state.isEditingHeader && - state.editingHeaderId == widget.groupData.id) { - title = _buildTextField(context); - } + if (state.isEditingHeader && + state.editingHeaderId == widget.groupData.id) { + title = _buildTextField(context); + } - return AppFlowyGroupHeader( - title: title, - icon: _buildHeaderIcon(boardCustomData), - addIcon: SizedBox( - height: 20, - width: 20, - child: FlowySvg( - FlowySvgs.add_s, - color: Theme.of(context).iconTheme.color, - ), - ), - onAddButtonClick: () => context - .read() - .add(BoardEvent.createHeaderRow(widget.groupData.id)), + return Padding( + padding: widget.margin, + child: SizedBox( height: 50, - margin: widget.margin ?? EdgeInsets.zero, - ); - }, - ), + child: Row( + children: [ + _buildHeaderIcon(boardCustomData), + title, + const HSpace(6), + _groupOptionsButton(context), + const HSpace(4), + FlowyTooltip( + message: LocaleKeys.board_column_addToColumnTopTooltip.tr(), + child: FlowyIconButton( + width: 20, + icon: const FlowySvg(FlowySvgs.add_s), + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + onPressed: () => context + .read() + .add(BoardEvent.createHeaderRow(widget.groupData.id)), + ), + ), + ], + ), + ), + ); + }, ); } @@ -157,7 +158,6 @@ class _BoardColumnHeaderState extends State { filled: true, fillColor: Theme.of(context).colorScheme.surface, hoverColor: Colors.transparent, - // Magic number 4 makes the textField take up the same space as FlowyText contentPadding: EdgeInsets.symmetric( vertical: CardSizes.cardCellVPadding + 4, horizontal: 8, @@ -184,45 +184,93 @@ class _BoardColumnHeaderState extends State { ); } - void _saveEdit() { - context.read().add( - BoardEvent.endEditingHeader( - widget.groupData.id, - _controller.text, + void _saveEdit() => context + .read() + .add(BoardEvent.endEditingHeader(widget.groupData.id, _controller.text)); + + Widget _buildHeaderIcon(GroupData customData) => + switch (customData.fieldType) { + FieldType.Checkbox => FlowySvg( + customData.asCheckboxGroup()!.isCheck + ? FlowySvgs.check_filled_s + : FlowySvgs.uncheck_s, + blendMode: BlendMode.dst, ), + _ => const SizedBox.shrink(), + }; + + Widget _groupOptionsButton(BuildContext context) { + return AppFlowyPopover( + clickHandler: PopoverClickHandler.gestureDetector, + margin: const EdgeInsets.fromLTRB(8, 8, 8, 4), + constraints: BoxConstraints.loose(const Size(168, 300)), + direction: PopoverDirection.bottomWithLeftAligned, + child: FlowyIconButton( + width: 20, + icon: const FlowySvg(FlowySvgs.details_horizontal_s), + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + ), + popupBuilder: (popoverContext) { + final customGroupData = widget.groupData.customData as GroupData; + final menuItems = GroupOptions.values.toList(); + if (!customGroupData.fieldType.canEditHeader) { + menuItems.remove(GroupOptions.rename); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...menuItems.map( + (action) => SizedBox( + height: GridSize.popoverItemHeight, + child: Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: FlowyButton( + leftIcon: FlowySvg(action.icon), + text: FlowyText.medium( + action.text, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + action.call(context, customGroupData.group); + PopoverContainer.of(popoverContext).close(); + }, + ), + ), + ), + ), + ], ); + }, + ); } } -Widget? _buildHeaderIcon(GroupData customData) { - Widget? widget; - switch (customData.fieldType) { - case FieldType.Checkbox: - final group = customData.asCheckboxGroup()!; - widget = FlowySvg( - group.isCheck ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, - blendMode: BlendMode.dst, - ); - break; - case FieldType.DateTime: - case FieldType.LastEditedTime: - case FieldType.CreatedTime: - case FieldType.MultiSelect: - case FieldType.Number: - case FieldType.RichText: - case FieldType.SingleSelect: - case FieldType.URL: - case FieldType.Checklist: - break; - } +enum GroupOptions { + rename, + hide; - if (widget != null) { - widget = SizedBox( - width: 20, - height: 20, - child: widget, - ); + void call(BuildContext context, GroupPB group) { + switch (this) { + case rename: + context + .read() + .add(BoardEvent.startEditingHeader(group.groupId)); + break; + case hide: + context + .read() + .add(BoardEvent.toggleGroupVisibility(group, false)); + break; + } } - return null; + FlowySvgData get icon => switch (this) { + rename => FlowySvgs.edit_s, + hide => FlowySvgs.hide_s, + }; + + String get text => switch (this) { + rename => LocaleKeys.board_column_renameColumn.tr(), + hide => LocaleKeys.board_column_hideColumn.tr(), + }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/board/presentation/widgets/board_hidden_groups.dart b/frontend/appflowy_flutter/lib/plugins/database_view/board/presentation/widgets/board_hidden_groups.dart new file mode 100644 index 000000000000..b5673de40074 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database_view/board/presentation/widgets/board_hidden_groups.dart @@ -0,0 +1,483 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart'; +import 'package:appflowy/plugins/database_view/application/database_controller.dart'; +import 'package:appflowy/plugins/database_view/application/field/field_info.dart'; +import 'package:appflowy/plugins/database_view/application/row/row_cache.dart'; +import 'package:appflowy/plugins/database_view/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database_view/board/application/board_bloc.dart'; +import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database_view/widgets/card/card_cell_builder.dart'; +import 'package:appflowy/plugins/database_view/widgets/card/cells/card_cell.dart'; +import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart'; +import 'package:appflowy/plugins/database_view/widgets/row/cells/text_cell/text_cell_bloc.dart'; +import 'package:appflowy/plugins/database_view/widgets/row/row_detail.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class HiddenGroupsColumn extends StatelessWidget { + final EdgeInsets margin; + const HiddenGroupsColumn({super.key, required this.margin}); + + @override + Widget build(BuildContext context) { + final databaseController = context.read().databaseController; + return BlocSelector( + selector: (state) => state.layoutSettings, + builder: (context, layoutSettings) { + if (layoutSettings == null) { + return const SizedBox.shrink(); + } + final isCollapsed = layoutSettings.collapseHiddenGroups; + return AnimatedSize( + alignment: AlignmentDirectional.topStart, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 150), + child: isCollapsed + ? SizedBox( + height: 50, + child: Padding( + padding: const EdgeInsets.only(left: 40, right: 8), + child: Center( + child: _collapseExpandIcon(context, isCollapsed), + ), + ), + ) + : SizedBox( + width: 260, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 50, + child: Padding( + padding: EdgeInsets.only( + left: 40 + margin.left, + right: margin.right, + ), + child: Row( + children: [ + Expanded( + child: FlowyText.medium( + LocaleKeys + .board_hiddenGroupSection_sectionTitle + .tr(), + fontSize: 14, + overflow: TextOverflow.ellipsis, + color: Theme.of(context).hintColor, + ), + ), + _collapseExpandIcon(context, isCollapsed), + ], + ), + ), + ), + Expanded( + child: HiddenGroupList( + databaseController: databaseController, + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _collapseExpandIcon(BuildContext context, bool isCollapsed) { + return FlowyTooltip( + message: isCollapsed + ? LocaleKeys.board_hiddenGroupSection_expandTooltip.tr() + : LocaleKeys.board_hiddenGroupSection_collapseTooltip.tr(), + child: FlowyIconButton( + width: 20, + height: 20, + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + onPressed: () => context + .read() + .add(BoardEvent.toggleHiddenSectionVisibility(!isCollapsed)), + icon: FlowySvg( + isCollapsed + ? FlowySvgs.hamburger_s_s + : FlowySvgs.pull_left_outlined_s, + ), + ), + ); + } +} + +class HiddenGroupList extends StatelessWidget { + const HiddenGroupList({ + super.key, + required this.databaseController, + }); + + final DatabaseController databaseController; + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + return BlocBuilder( + builder: (_, state) => ReorderableListView.builder( + proxyDecorator: (child, index, animation) => Material( + color: Colors.transparent, + child: Stack( + children: [ + child, + MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grabbing, + child: const SizedBox.expand(), + ), + ], + ), + ), + buildDefaultDragHandles: false, + itemCount: state.hiddenGroups.length, + itemBuilder: (_, index) => Padding( + padding: const EdgeInsets.only(bottom: 4), + key: ValueKey("hiddenGroup${state.hiddenGroups[index].groupId}"), + child: HiddenGroupCard( + group: state.hiddenGroups[index], + index: index, + bloc: bloc, + ), + ), + onReorder: (oldIndex, newIndex) { + if (oldIndex < newIndex) { + newIndex--; + } + final fromGroupId = state.hiddenGroups[oldIndex].groupId; + final toGroupId = state.hiddenGroups[newIndex].groupId; + bloc.add(BoardEvent.reorderGroup(fromGroupId, toGroupId)); + }, + ), + ); + } +} + +class HiddenGroupCard extends StatefulWidget { + const HiddenGroupCard({ + super.key, + required this.group, + required this.index, + required this.bloc, + }); + + final GroupPB group; + final BoardBloc bloc; + final int index; + + @override + State createState() => _HiddenGroupCardState(); +} + +class _HiddenGroupCardState extends State { + final PopoverController _popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + final databaseController = widget.bloc.databaseController; + final primaryField = databaseController.fieldController.fieldInfos + .firstWhereOrNull((element) => element.isPrimary)!; + + return Padding( + padding: const EdgeInsets.only(left: 26), + child: AppFlowyPopover( + controller: _popoverController, + direction: PopoverDirection.bottomWithCenterAligned, + triggerActions: PopoverTriggerFlags.none, + constraints: const BoxConstraints(maxWidth: 234, maxHeight: 300), + popupBuilder: (popoverContext) => HiddenGroupPopupItemList( + bloc: widget.bloc, + viewId: databaseController.viewId, + groupId: widget.group.groupId, + primaryField: primaryField, + rowCache: databaseController.rowCache, + ), + child: HiddenGroupButtonContent( + popoverController: _popoverController, + groupId: widget.group.groupId, + index: widget.index, + bloc: widget.bloc, + ), + ), + ); + } +} + +class HiddenGroupButtonContent extends StatelessWidget { + final String groupId; + final int index; + final BoardBloc bloc; + const HiddenGroupButtonContent({ + super.key, + required this.popoverController, + required this.groupId, + required this.index, + required this.bloc, + }); + + final PopoverController popoverController; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: popoverController.show, + child: FlowyHover( + builder: (context, isHovering) { + return BlocProvider.value( + value: bloc, + child: BlocBuilder( + builder: (context, state) { + final group = state.hiddenGroups.firstWhereOrNull( + (g) => g.groupId == groupId, + ); + if (group == null) { + return const SizedBox.shrink(); + } + + return SizedBox( + height: 30, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 3, + ), + child: Row( + children: [ + HiddenGroupCardActions( + isVisible: isHovering, + index: index, + ), + const HSpace(4), + FlowyText.medium( + group.groupName, + overflow: TextOverflow.ellipsis, + ), + const HSpace(6), + Expanded( + child: FlowyText.medium( + group.rows.length.toString(), + overflow: TextOverflow.ellipsis, + color: Theme.of(context).hintColor, + ), + ), + if (isHovering) ...[ + FlowyIconButton( + width: 20, + icon: FlowySvg( + FlowySvgs.show_m, + color: Theme.of(context).hintColor, + ), + onPressed: () => context.read().add( + BoardEvent.toggleGroupVisibility( + group, + true, + ), + ), + ), + ], + ], + ), + ), + ); + }, + ), + ); + }, + ), + ), + ); + } +} + +class HiddenGroupCardActions extends StatelessWidget { + final bool isVisible; + final int index; + + const HiddenGroupCardActions({ + super.key, + required this.isVisible, + required this.index, + }); + + @override + Widget build(BuildContext context) { + return ReorderableDragStartListener( + index: index, + enabled: isVisible, + child: MouseRegion( + cursor: SystemMouseCursors.grab, + child: SizedBox( + height: 14, + width: 14, + child: isVisible + ? FlowySvg( + FlowySvgs.drag_element_s, + color: Theme.of(context).hintColor, + ) + : const SizedBox.shrink(), + ), + ), + ); + } +} + +class HiddenGroupPopupItemList extends StatelessWidget { + const HiddenGroupPopupItemList({ + required this.bloc, + required this.groupId, + required this.viewId, + required this.primaryField, + required this.rowCache, + super.key, + }); + + final BoardBloc bloc; + final String groupId; + final String viewId; + final FieldInfo primaryField; + final RowCache rowCache; + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: bloc, + child: BlocBuilder( + builder: (context, state) { + final group = state.hiddenGroups.firstWhereOrNull( + (g) => g.groupId == groupId, + ); + if (group == null) { + return const SizedBox.shrink(); + } + final cells = [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: FlowyText.medium( + group.groupName, + fontSize: 10, + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ), + ), + ...group.rows.map( + (item) { + final rowController = RowController( + rowMeta: item, + viewId: viewId, + rowCache: rowCache, + ); + final renderHook = RowCardRenderHook(); + renderHook.addTextCellHook((cellData, _, __) { + return BlocBuilder( + builder: (context, state) { + final text = cellData.isEmpty + ? LocaleKeys.grid_row_titlePlaceholder.tr() + : cellData; + + if (text.isEmpty) { + return const SizedBox.shrink(); + } + + return Align( + alignment: Alignment.centerLeft, + child: FlowyText.medium( + text, + textAlign: TextAlign.left, + fontSize: 11, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + }, + ); + }); + + return HiddenGroupPopupItem( + cellContext: rowCache.loadCells(item)[primaryField.id]!, + primaryField: primaryField, + rowController: rowController, + cellBuilder: CardCellBuilder(rowController.cellCache), + renderHook: renderHook, + onPressed: () { + FlowyOverlay.show( + context: context, + builder: (BuildContext context) { + return RowDetailPage( + cellBuilder: GridCellBuilder( + cellCache: rowController.cellCache, + ), + rowController: rowController, + ); + }, + ); + PopoverContainer.of(context).close(); + }, + ); + }, + ), + ]; + + return ListView.separated( + itemBuilder: (context, index) => cells[index], + itemCount: cells.length, + separatorBuilder: (context, index) => + VSpace(GridSize.typeOptionSeparatorHeight), + shrinkWrap: true, + ); + }, + ), + ); + } +} + +class HiddenGroupPopupItem extends StatelessWidget { + const HiddenGroupPopupItem({ + super.key, + required this.cellContext, + required this.onPressed, + required this.cellBuilder, + required this.rowController, + required this.primaryField, + required this.renderHook, + }); + + final DatabaseCellContext cellContext; + final FieldInfo primaryField; + final RowController rowController; + final CardCellBuilder cellBuilder; + final RowCardRenderHook renderHook; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 26, + child: FlowyButton( + margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + text: cellBuilder.buildCell( + cellContext: cellContext, + renderHook: renderHook, + hasNotes: !cellContext.rowMeta.isDocumentEmpty, + ), + onTap: onPressed, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/calendar_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/calendar_bloc.dart index 6a518e3975c6..0a08d33a7448 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/calendar_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/calendar_bloc.dart @@ -198,7 +198,9 @@ class CalendarBloc extends Bloc { Future _updateCalendarLayoutSetting( CalendarLayoutSettingPB layoutSetting, ) async { - return databaseController.updateLayoutSetting(layoutSetting); + return databaseController.updateLayoutSetting( + calendarLayoutSetting: layoutSetting, + ); } Future?> _loadEvent(RowId rowId) async { @@ -281,7 +283,7 @@ class CalendarBloc extends Bloc { return; } fieldInfoByFieldId = { - for (var fieldInfo in fieldInfos) fieldInfo.field.id: fieldInfo + for (final fieldInfo in fieldInfos) fieldInfo.field.id: fieldInfo, }; }, onRowsCreated: (rowIds) async { @@ -319,14 +321,13 @@ class CalendarBloc extends Bloc { }, ); - final onLayoutChanged = DatabaseLayoutSettingCallbacks( - onLayoutChanged: _didReceiveLayoutSetting, - onLoadLayout: _didReceiveLayoutSetting, + final onLayoutSettingsChanged = DatabaseLayoutSettingCallbacks( + onLayoutSettingsChanged: _didReceiveLayoutSetting, ); databaseController.addListener( onDatabaseChanged: onDatabaseChanged, - onLayoutChanged: onLayoutChanged, + onLayoutSettingsChanged: onLayoutSettingsChanged, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/calendar.dart b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/calendar.dart index 4a85ff373907..10c2e5414cbc 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/calendar.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/calendar.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_view.dart'; +import 'package:appflowy/plugins/database_view/tab_bar/tab_bar_view.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_event_card.dart b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_event_card.dart index 80b26e9b2ef5..b7301bc880f5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_event_card.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_event_card.dart @@ -230,7 +230,7 @@ class _EventCardState extends State { color: Theme.of(context).hintColor, overflow: TextOverflow.ellipsis, ), - ) + ), ], ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_event_editor.dart b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_event_editor.dart index 8ccef33fdf48..12b568dfb46d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_event_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_event_editor.dart @@ -144,7 +144,7 @@ class EventPropertyList extends StatelessWidget { textStyle: Theme.of(context) .textTheme .bodyMedium - ?.copyWith(fontSize: 11), + ?.copyWith(fontSize: 11, overflow: TextOverflow.ellipsis), autofocus: true, useRoundedBorder: true, ), @@ -213,10 +213,13 @@ class _PropertyCellState extends State { size: const Size.square(14), ), const HSpace(4.0), - FlowyText.regular( - widget.cellContext.fieldInfo.name, - color: Theme.of(context).hintColor, - fontSize: 11, + Expanded( + child: FlowyText.regular( + widget.cellContext.fieldInfo.name, + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + fontSize: 11, + ), ), ], ), diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_page.dart b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_page.dart index 1fbd80ae3dcb..a3618aab6cee 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_page.dart @@ -4,7 +4,7 @@ import 'package:appflowy/plugins/database_view/application/database_controller.d import 'package:appflowy/plugins/database_view/calendar/application/calendar_bloc.dart'; import 'package:appflowy/plugins/database_view/calendar/application/unschedule_event_bloc.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart'; -import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_view.dart'; +import 'package:appflowy/plugins/database_view/tab_bar/tab_bar_view.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/calendar_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; @@ -14,6 +14,7 @@ import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -375,9 +376,13 @@ class _UnscheduledEventsButtonState extends State { _popoverController.show(); } }, - child: FlowyText.regular( - "${LocaleKeys.calendar_settings_noDateTitle.tr()} (${state.unscheduleEvents.length})", - fontSize: 10, + child: FlowyTooltip( + message: LocaleKeys.calendar_settings_noDateHint + .plural(state.unscheduleEvents.length), + child: FlowyText.regular( + "${LocaleKeys.calendar_settings_noDateTitle.tr()} (${state.unscheduleEvents.length})", + fontSize: 10, + ), ), ), popupBuilder: (context) { @@ -430,7 +435,7 @@ class UnscheduleEventsList extends StatelessWidget { PopoverContainer.of(context).close(); }, ), - ) + ), ]; return ListView.separated( diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/toolbar/calendar_layout_setting.dart b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/toolbar/calendar_layout_setting.dart index 7b4a03142427..d9149b996654 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/toolbar/calendar_layout_setting.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/toolbar/calendar_layout_setting.dart @@ -4,7 +4,6 @@ import 'package:appflowy/plugins/database_view/application/field/field_controlle import 'package:appflowy/plugins/database_view/application/setting/property_bloc.dart'; import 'package:appflowy/plugins/database_view/calendar/application/calendar_setting_bloc.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart'; -import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; @@ -234,9 +233,9 @@ class LayoutDateField extends StatelessWidget { offset: const Offset(-14, 0), popupBuilder: (context) { return BlocProvider( - create: (context) => getIt( - param1: viewId, - param2: fieldController, + create: (context) => DatabasePropertyBloc( + viewId: viewId, + fieldController: fieldController, )..add(const DatabasePropertyEvent.initial()), child: BlocBuilder( builder: (context, state) { diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/grid_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/grid_bloc.dart index d5fef3b3c7d3..f1b976a1cb04 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/grid_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/grid_bloc.dart @@ -6,13 +6,11 @@ import 'package:appflowy/plugins/database_view/application/row/row_service.dart' import 'package:appflowy/plugins/database_view/grid/presentation/widgets/filter/filter_info.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/widgets/sort/sort_info.dart'; import 'package:dartz/dartz.dart'; -import 'package:equatable/equatable.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../../application/database_controller.dart'; -import 'dart:collection'; part 'grid_bloc.freezed.dart'; @@ -54,7 +52,7 @@ class GridBloc extends Bloc { didReceiveFieldUpdate: (fields) { emit( state.copyWith( - fields: GridFieldEquatable(fields), + fields: FieldList(fields), ), ); }, @@ -175,7 +173,7 @@ class GridState with _$GridState { const factory GridState({ required String viewId, required Option grid, - required GridFieldEquatable fields, + required FieldList fields, required List rowInfos, required int rowCount, required LoadingState loadingState, @@ -186,7 +184,7 @@ class GridState with _$GridState { }) = _GridState; factory GridState.initial(String viewId) => GridState( - fields: GridFieldEquatable(UnmodifiableListView([])), + fields: FieldList([]), rowInfos: [], rowCount: 0, grid: none(), @@ -199,26 +197,7 @@ class GridState with _$GridState { ); } -class GridFieldEquatable extends Equatable { - final List _fieldInfos; - const GridFieldEquatable( - List fieldInfos, - ) : _fieldInfos = fieldInfos; - - @override - List get props { - if (_fieldInfos.isEmpty) { - return []; - } - - return [ - _fieldInfos.length, - _fieldInfos - .map((fieldInfo) => fieldInfo.field.width) - .reduce((value, element) => value + element), - ]; - } - - UnmodifiableListView get value => - UnmodifiableListView(_fieldInfos); +@freezed +class FieldList with _$FieldList { + factory FieldList(List fields) = _FieldList; } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/grid_header_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/grid_header_bloc.dart index 173a74bc8773..c854dbb33528 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/grid_header_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/grid_header_bloc.dart @@ -21,7 +21,7 @@ class GridHeaderBloc extends Bloc { on( (event, emit) async { await event.map( - initial: (_InitialHeader value) async { + initial: (_InitialHeader value) { _startListening(); add( GridHeaderEvent.didReceiveFieldUpdate(fieldController.fieldInfos), @@ -65,7 +65,7 @@ class GridHeaderBloc extends Bloc { result.fold((l) {}, (err) => Log.error(err)); } - Future _startListening() async { + void _startListening() { fieldController.addListener( onReceiveFields: (fields) => add(GridHeaderEvent.didReceiveFieldUpdate(fields)), diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/row/row_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/row/row_bloc.dart index 9842bd5f0c1e..cf436df89ff6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/row/row_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/row/row_bloc.dart @@ -132,7 +132,7 @@ class GridCellEquatable extends Equatable { _fieldInfo.id, _fieldInfo.fieldType, _fieldInfo.field.visibility, - _fieldInfo.field.width, + _fieldInfo.fieldSettings?.width, ]; } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/row/row_document_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/row/row_document_bloc.dart index 0e64ba597c71..9eedbbc7e909 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/row/row_document_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/row/row_document_bloc.dart @@ -5,6 +5,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; @@ -43,6 +44,14 @@ class RowDocumentBloc extends Bloc { ), ); }, + updateIsEmpty: (isEmpty) async { + final unitOrFailure = await _rowBackendSvc.updateMeta( + rowId: rowId, + isDocumentEmpty: isEmpty, + ); + + unitOrFailure.fold((l) => null, (err) => Log.error(err)); + }, ); }, ); @@ -104,6 +113,8 @@ class RowDocumentEvent with _$RowDocumentEvent { _DidReceiveRowDocument; const factory RowDocumentEvent.didReceiveError(FlowyError error) = _DidReceiveError; + const factory RowDocumentEvent.updateIsEmpty(bool isDocumentEmpty) = + _UpdateIsEmpty; } @freezed diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/grid.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/grid.dart index 6bce729003f6..06273bc471a7 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/grid.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/grid.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_view.dart'; +import 'package:appflowy/plugins/database_view/tab_bar/tab_bar_view.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/grid_page.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/grid_page.dart index defba2c6bff4..ced36703d613 100755 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/grid_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/grid_page.dart @@ -1,7 +1,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database_view/application/row/row_service.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/widgets/toolbar/grid_setting_bar.dart'; -import 'package:appflowy/plugins/database_view/tar_bar/setting_menu.dart'; +import 'package:appflowy/plugins/database_view/tab_bar/setting_menu.dart'; import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart'; import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -20,7 +20,7 @@ import '../../application/row/row_controller.dart'; import '../application/grid_bloc.dart'; import '../../application/database_controller.dart'; import 'grid_scroll.dart'; -import '../../tar_bar/tab_bar_view.dart'; +import '../../tab_bar/tab_bar_view.dart'; import 'layout/layout.dart'; import 'layout/sizes.dart'; import 'widgets/row/row.dart'; @@ -172,7 +172,7 @@ class _GridPageContentState extends State { return BlocBuilder( buildWhen: (previous, current) => previous.fields != current.fields, builder: (context, state) { - final contentWidth = GridLayout.headerWidth(state.fields.value); + final contentWidth = GridLayout.headerWidth(state.fields.fields); return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/layout/layout.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/layout/layout.dart index 29acb595d1c5..2b1882be1f9b 100755 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/layout/layout.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/layout/layout.dart @@ -6,7 +6,7 @@ class GridLayout { if (fields.isEmpty) return 0; final fieldsWidth = fields - .map((fieldInfo) => fieldInfo.field.width.toDouble()) + .map((fieldInfo) => fieldInfo.fieldSettings!.width.toDouble()) .reduce((value, element) => value + element); return fieldsWidth + diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/layout/sizes.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/layout/sizes.dart index 1acb6eccdc42..fbd05e952e86 100755 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/layout/sizes.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/layout/sizes.dart @@ -6,7 +6,7 @@ class GridSize { static double get scrollBarSize => 8 * scale; static double get headerHeight => 40 * scale; static double get footerHeight => 40 * scale; - static double get leadingHeaderPadding => 50 * scale; + static double get leadingHeaderPadding => 40 * scale; static double get trailHeaderPadding => 140 * scale; static double get headerContainerPadding => 0 * scale; static double get cellHPadding => 10 * scale; diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_cell.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_cell.dart index e3cacc6140d9..6d832235c0f4 100755 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_cell.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database_view/application/field/field_cell_bloc.dart'; -import 'package:appflowy/plugins/database_view/application/field/field_service.dart'; +import 'package:appflowy/plugins/database_view/application/field/field_info.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra/theme_extension.dart'; @@ -15,31 +15,41 @@ import 'field_cell_action_sheet.dart'; import 'field_type_extension.dart'; class GridFieldCell extends StatefulWidget { - final FieldContext cellContext; + final String viewId; + final FieldInfo fieldInfo; const GridFieldCell({ - Key? key, - required this.cellContext, - }) : super(key: key); + super.key, + required this.viewId, + required this.fieldInfo, + }); @override State createState() => _GridFieldCellState(); } class _GridFieldCellState extends State { + late final FieldCellBloc _bloc; late PopoverController popoverController; @override void initState() { - popoverController = PopoverController(); super.initState(); + popoverController = PopoverController(); + _bloc = FieldCellBloc(viewId: widget.viewId, fieldInfo: widget.fieldInfo); + } + + @override + didUpdateWidget(covariant oldWidget) { + if (widget.fieldInfo != oldWidget.fieldInfo && !_bloc.isClosed) { + _bloc.add(FieldCellEvent.onFieldChanged(widget.fieldInfo)); + } + super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) { - return FieldCellBloc(fieldContext: widget.cellContext); - }, + return BlocProvider.value( + value: _bloc, child: BlocBuilder( builder: (context, state) { final button = AppFlowyPopover( @@ -50,11 +60,12 @@ class _GridFieldCellState extends State { controller: popoverController, popupBuilder: (BuildContext context) { return GridFieldCellActionSheet( - cellContext: widget.cellContext, + viewId: widget.viewId, + fieldInfo: widget.fieldInfo, ); }, child: FieldCellButton( - field: widget.cellContext.fieldInfo.field, + field: widget.fieldInfo.field, onTap: () => popoverController.show(), ), ); @@ -78,6 +89,12 @@ class _GridFieldCellState extends State { ), ); } + + @override + Future dispose() async { + super.dispose(); + await _bloc.close(); + } } class _GridHeaderCellContainer extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_cell_action_sheet.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_cell_action_sheet.dart index 3f4de78de92e..199ac1e6e079 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_cell_action_sheet.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_cell_action_sheet.dart @@ -1,8 +1,8 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database_view/application/field/field_action_sheet_bloc.dart'; +import 'package:appflowy/plugins/database_view/application/field/field_info.dart'; import 'package:appflowy/plugins/database_view/application/field/field_service.dart'; import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart'; -import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; @@ -20,9 +20,13 @@ import '../../layout/sizes.dart'; import 'field_editor.dart'; class GridFieldCellActionSheet extends StatefulWidget { - final FieldContext cellContext; - const GridFieldCellActionSheet({required this.cellContext, Key? key}) - : super(key: key); + final String viewId; + final FieldInfo fieldInfo; + const GridFieldCellActionSheet({ + required this.viewId, + required this.fieldInfo, + Key? key, + }) : super(key: key); @override State createState() => _GridFieldCellActionSheetState(); @@ -37,31 +41,35 @@ class _GridFieldCellActionSheetState extends State { return SizedBox( width: 400, child: FieldEditor( - viewId: widget.cellContext.viewId, - fieldInfo: widget.cellContext.fieldInfo, + viewId: widget.viewId, + fieldInfo: widget.fieldInfo, typeOptionLoader: FieldTypeOptionLoader( - viewId: widget.cellContext.viewId, - field: widget.cellContext.fieldInfo.field, + viewId: widget.viewId, + field: widget.fieldInfo.field, ), ), ); } return BlocProvider( - create: (context) => - getIt(param1: widget.cellContext), + create: (context) => FieldActionSheetBloc( + viewId: widget.viewId, + fieldInfo: widget.fieldInfo, + ), child: IntrinsicWidth( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ _EditFieldButton( - cellContext: widget.cellContext, onTap: () { setState(() => _showFieldEditor = true); }, ), VSpace(GridSize.typeOptionSeparatorHeight), - _FieldOperationList(widget.cellContext), + _FieldOperationList( + viewId: widget.viewId, + fieldInfo: widget.fieldInfo, + ), ], ), ), @@ -70,10 +78,8 @@ class _GridFieldCellActionSheetState extends State { } class _EditFieldButton extends StatelessWidget { - final FieldContext cellContext; final void Function()? onTap; - const _EditFieldButton({required this.cellContext, Key? key, this.onTap}) - : super(key: key); + const _EditFieldButton({Key? key, this.onTap}) : super(key: key); @override Widget build(BuildContext context) { @@ -96,8 +102,13 @@ class _EditFieldButton extends StatelessWidget { } class _FieldOperationList extends StatelessWidget { - final FieldContext fieldContext; - const _FieldOperationList(this.fieldContext, {Key? key}) : super(key: key); + final String viewId; + final FieldInfo fieldInfo; + const _FieldOperationList({ + required this.viewId, + required this.fieldInfo, + Key? key, + }) : super(key: key); @override Widget build(BuildContext context) { @@ -128,7 +139,7 @@ class _FieldOperationList extends StatelessWidget { bool enable = true; // If the field is primary, delete and duplicate are disabled. - if (fieldContext.fieldInfo.isPrimary) { + if (fieldInfo.isPrimary) { switch (action) { case FieldAction.hide: break; @@ -145,7 +156,8 @@ class _FieldOperationList extends StatelessWidget { child: SizedBox( height: GridSize.popoverItemHeight, child: FieldActionCell( - fieldInfo: fieldContext, + viewId: viewId, + fieldInfo: fieldInfo, action: action, enable: enable, ), @@ -155,11 +167,13 @@ class _FieldOperationList extends StatelessWidget { } class FieldActionCell extends StatelessWidget { - final FieldContext fieldInfo; + final String viewId; + final FieldInfo fieldInfo; final FieldAction action; final bool enable; const FieldActionCell({ + required this.viewId, required this.fieldInfo, required this.action, required this.enable, @@ -177,7 +191,7 @@ class FieldActionCell extends StatelessWidget { ? AFThemeExtension.of(context).textColor : Theme.of(context).disabledColor, ), - onTap: () => action.run(context, fieldInfo), + onTap: () => action.run(context, viewId, fieldInfo), leftIcon: FlowySvg( action.icon(), color: enable @@ -217,7 +231,7 @@ extension _FieldActionExtension on FieldAction { } } - void run(BuildContext context, FieldContext fieldContext) { + void run(BuildContext context, String viewId, FieldInfo fieldInfo) { switch (this) { case FieldAction.hide: context @@ -228,8 +242,8 @@ extension _FieldActionExtension on FieldAction { PopoverContainer.of(context).close(); FieldBackendService( - viewId: fieldContext.viewId, - fieldId: fieldContext.fieldInfo.id, + viewId: viewId, + fieldId: fieldInfo.id, ).duplicateField(); break; @@ -240,8 +254,8 @@ extension _FieldActionExtension on FieldAction { title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(), confirm: () { FieldBackendService( - viewId: fieldContext.viewId, - fieldId: fieldContext.fieldInfo.field.id, + viewId: viewId, + fieldId: fieldInfo.field.id, ).deleteField(); }, ).show(context); diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart index 5a10f6d6c273..a1a573341dcd 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart @@ -59,4 +59,10 @@ extension FieldTypeListExtension on FieldType { FieldType.SingleSelect => true, _ => false, }; + + bool get canCreateNewGroup => switch (this) { + FieldType.MultiSelect => true, + FieldType.SingleSelect => true, + _ => false, + }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_type_option_editor.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_type_option_editor.dart index e5b241883454..6ea45ce5bb2f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_type_option_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_type_option_editor.dart @@ -50,7 +50,7 @@ class FieldTypeOptionEditor extends StatelessWidget { final List children = [ SwitchFieldButton(popoverMutex: popoverMutex), - if (typeOptionWidget != null) typeOptionWidget + if (typeOptionWidget != null) typeOptionWidget, ]; return ListView( diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/grid_header.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/grid_header.dart index 71f9f1375fa1..022991015a8c 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/grid_header.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/grid_header.dart @@ -1,10 +1,8 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database_view/application/field/field_controller.dart'; -import 'package:appflowy/plugins/database_view/application/field/field_service.dart'; import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart'; import 'package:appflowy/plugins/database_view/grid/application/grid_header_bloc.dart'; -import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; @@ -42,9 +40,9 @@ class _GridHeaderSliverAdaptorState extends State { Widget build(BuildContext context) { return BlocProvider( create: (context) { - return getIt( - param1: widget.viewId, - param2: widget.fieldController, + return GridHeaderBloc( + viewId: widget.viewId, + fieldController: widget.fieldController, )..add(const GridHeaderEvent.initial()); }, child: BlocBuilder( @@ -96,15 +94,10 @@ class _GridHeaderState extends State<_GridHeader> { builder: (context, state) { final cells = state.fields .map( - (field) => FieldContext( + (fieldInfo) => GridFieldCell( + key: _getKeyById(fieldInfo.id), viewId: widget.viewId, - fieldInfo: field, - ), - ) - .map( - (ctx) => GridFieldCell( - key: _getKeyById(ctx.fieldInfo.id), - cellContext: ctx, + fieldInfo: fieldInfo, ), ) .toList(); @@ -136,7 +129,7 @@ class _GridHeaderState extends State<_GridHeader> { int newIndex, ) { if (cells.length > oldIndex) { - final field = cells[oldIndex].cellContext.fieldInfo.field; + final field = cells[oldIndex].fieldInfo.field; context .read() .add(GridHeaderEvent.moveField(field, oldIndex, newIndex)); diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/type_option/select_option.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/type_option/select_option.dart index 9dd452913993..e3399e682c51 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/type_option/select_option.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/type_option/select_option.dart @@ -48,7 +48,7 @@ class SelectOptionTypeOptionWidget extends StatelessWidget { const VSpace(10), if (state.options.isEmpty && !state.isEditingOption) const _AddOptionButton(), - _OptionList(popoverMutex: popoverMutex) + _OptionList(popoverMutex: popoverMutex), ]; return ListView.builder( @@ -77,7 +77,7 @@ class OptionTitle extends StatelessWidget { child: FlowyText.medium( LocaleKeys.grid_field_optionTitle.tr(), ), - ) + ), ]; if (state.options.isNotEmpty && !state.isEditingOption) { children.add(const Spacer()); diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/row/row.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/row/row.dart index f1f0976e8486..96bf4baf6475 100755 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/row/row.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/row/row.dart @@ -276,7 +276,7 @@ class RowContent extends StatelessWidget { final GridCellWidget child = builder.build(cellId); return CellContainer( - width: cellId.fieldInfo.field.width.toDouble(), + width: cellId.fieldInfo.fieldSettings?.width.toDouble() ?? 140, isPrimary: cellId.fieldInfo.field.isPrimary, cellContainerNotifier: CellContainerNotifier(child), accessoryBuilder: (buildContext) { diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/shortcuts.dart index 1e38c5647d4a..d3f7e7e9c462 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/shortcuts.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/shortcuts.dart @@ -19,7 +19,7 @@ class GridShortcuts extends StatelessWidget { } Map bindKeys(List keys) { - return {for (var key in keys) LogicalKeySet(key): KeyboardKeyIdent(key)}; + return {for (final key in keys) LogicalKeySet(key): KeyboardKeyIdent(key)}; } Map> bindActions() { diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/setting_menu.dart b/frontend/appflowy_flutter/lib/plugins/database_view/tab_bar/setting_menu.dart similarity index 100% rename from frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/setting_menu.dart rename to frontend/appflowy_flutter/lib/plugins/database_view/tab_bar/setting_menu.dart diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tar_bar_add_button.dart b/frontend/appflowy_flutter/lib/plugins/database_view/tab_bar/tab_bar_add_button.dart similarity index 100% rename from frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tar_bar_add_button.dart rename to frontend/appflowy_flutter/lib/plugins/database_view/tab_bar/tab_bar_add_button.dart diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tab_bar_header.dart b/frontend/appflowy_flutter/lib/plugins/database_view/tab_bar/tab_bar_header.dart similarity index 89% rename from frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tab_bar_header.dart rename to frontend/appflowy_flutter/lib/plugins/database_view/tab_bar/tab_bar_header.dart index 9f9f5e2886cc..b83664b72548 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tab_bar_header.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/tab_bar/tab_bar_header.dart @@ -12,8 +12,8 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../application/tar_bar_bloc.dart'; -import 'tar_bar_add_button.dart'; +import '../application/tab_bar_bloc.dart'; +import 'tab_bar_add_button.dart'; class TabBarHeader extends StatefulWidget { const TabBarHeader({super.key}); @@ -40,14 +40,14 @@ class _TabBarHeaderState extends State { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - BlocBuilder( + BlocBuilder( builder: (context, state) { return const Flexible( child: DatabaseTabBar(), ); }, ), - BlocBuilder( + BlocBuilder( builder: (context, state) { return SizedBox( width: 200, @@ -66,7 +66,7 @@ class _TabBarHeaderState extends State { ); } - Widget pageSettingBarFromState(GridTabBarState state) { + Widget pageSettingBarFromState(DatabaseTabBarState state) { if (state.tabBars.length < state.selectedIndex) { return const SizedBox.shrink(); } @@ -92,7 +92,7 @@ class _DatabaseTabBarState extends State { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocBuilder( builder: (context, state) { final children = state.tabBars.indexed.map((indexed) { final isSelected = state.selectedIndex == indexed.$1; @@ -102,8 +102,8 @@ class _DatabaseTabBarState extends State { view: tabBar.view, isSelected: isSelected, onTap: (selectedView) { - context.read().add( - GridTabBarEvent.selectView(selectedView.id), + context.read().add( + DatabaseTabBarEvent.selectView(selectedView.id), ); }, ); @@ -122,8 +122,8 @@ class _DatabaseTabBarState extends State { ), AddDatabaseViewButton( onTap: (action) async { - context.read().add( - GridTabBarEvent.createView(action), + context.read().add( + DatabaseTabBarEvent.createView(action), ); }, ), @@ -231,8 +231,8 @@ class TabBarItemButton extends StatelessWidget { title: LocaleKeys.menuAppHeader_renameDialog.tr(), value: view.name, confirm: (newValue) { - context.read().add( - GridTabBarEvent.renameView(view.id, newValue), + context.read().add( + DatabaseTabBarEvent.renameView(view.id, newValue), ); }, ).show(context); @@ -241,8 +241,8 @@ class TabBarItemButton extends StatelessWidget { NavigatorAlertDialog( title: LocaleKeys.grid_deleteView.tr(), confirm: () { - context.read().add( - GridTabBarEvent.deleteView(view.id), + context.read().add( + DatabaseTabBarEvent.deleteView(view.id), ); }, ).show(context); diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tab_bar_view.dart b/frontend/appflowy_flutter/lib/plugins/database_view/tab_bar/tab_bar_view.dart similarity index 82% rename from frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tab_bar_view.dart rename to frontend/appflowy_flutter/lib/plugins/database_view/tab_bar/tab_bar_view.dart index a32e706bf659..1d80c56a2b18 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tab_bar_view.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/tab_bar/tab_bar_view.dart @@ -1,16 +1,15 @@ -import 'package:appflowy/plugins/database_view/application/tar_bar_bloc.dart'; +import 'package:appflowy/plugins/database_view/application/tab_bar_bloc.dart'; import 'package:appflowy/plugins/database_view/widgets/share_button.dart'; import 'package:appflowy/plugins/util.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; -import 'package:appflowy/workspace/presentation/widgets/left_bar_item.dart'; import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart'; +import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../application/database_controller.dart'; -import '../grid/presentation/layout/sizes.dart'; import 'tab_bar_header.dart'; abstract class DatabaseTabBarItemBuilder { @@ -64,14 +63,14 @@ class _DatabaseTabBarViewState extends State { @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => GridTabBarBloc(view: widget.view) + return BlocProvider( + create: (context) => DatabaseTabBarBloc(view: widget.view) ..add( - const GridTabBarEvent.initial(), + const DatabaseTabBarEvent.initial(), ), child: MultiBlocListener( listeners: [ - BlocListener( + BlocListener( listenWhen: (p, c) => p.selectedIndex != c.selectedIndex, listener: (context, state) { _pageController?.animateToPage( @@ -84,7 +83,7 @@ class _DatabaseTabBarViewState extends State { ], child: Column( children: [ - BlocBuilder( + BlocBuilder( builder: (context, state) { return ValueListenableBuilder( valueListenable: state @@ -95,26 +94,24 @@ class _DatabaseTabBarViewState extends State { if (value) { return const SizedBox.shrink(); } - return SizedBox( + return const SizedBox( height: 30, child: Padding( - padding: EdgeInsets.symmetric( - horizontal: GridSize.leadingHeaderPadding, - ), - child: const TabBarHeader(), + padding: EdgeInsets.symmetric(horizontal: 40), + child: TabBarHeader(), ), ); }, ); }, ), - BlocBuilder( + BlocBuilder( builder: (context, state) { return pageSettingBarExtensionFromState(state); }, ), Expanded( - child: BlocBuilder( + child: BlocBuilder( builder: (context, state) { return PageView( pageSnapping: false, @@ -131,7 +128,7 @@ class _DatabaseTabBarViewState extends State { ); } - List pageContentFromState(GridTabBarState state) { + List pageContentFromState(DatabaseTabBarState state) { return state.tabBars.map((tabBar) { final controller = state.tabBarControllerByViewId[tabBar.viewId]!.controller; @@ -144,7 +141,7 @@ class _DatabaseTabBarViewState extends State { }).toList(); } - Widget pageSettingBarExtensionFromState(GridTabBarState state) { + Widget pageSettingBarExtensionFromState(DatabaseTabBarState state) { if (state.tabBars.length < state.selectedIndex) { return const SizedBox.shrink(); } @@ -190,7 +187,7 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { }); @override - Widget get leftBarItem => ViewLeftBarItem(view: notifier.view); + Widget get leftBarItem => ViewTitleBar(view: notifier.view); @override Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view); @@ -216,13 +213,9 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { @override Widget? get rightBarItem { - return Row( - children: [ - DatabaseShareButton( - key: ValueKey(notifier.view.id), - view: notifier.view, - ), - ], + return DatabaseShareButton( + key: ValueKey(notifier.view.id), + view: notifier.view, ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card.dart index b399abc45115..b8d4d09f90d4 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card.dart @@ -48,23 +48,23 @@ class RowCard extends StatefulWidget { final RowCardStyleConfiguration styleConfiguration; const RowCard({ + super.key, required this.rowMeta, required this.viewId, - this.groupingFieldId, - this.groupId, required this.isEditing, required this.rowCache, required this.cellBuilder, required this.openCard, required this.onStartEditing, required this.onEndEditing, + this.groupingFieldId, + this.groupId, this.cardData, this.styleConfiguration = const RowCardStyleConfiguration( showAccessory: true, ), this.renderHook, - Key? key, - }) : super(key: key); + }); @override State> createState() => @@ -79,6 +79,7 @@ class _RowCardState extends State> { @override void initState() { + super.initState(); rowNotifier = EditableRowNotifier(isEditing: widget.isEditing); _cardBloc = CardBloc( viewId: widget.viewId, @@ -100,7 +101,6 @@ class _RowCardState extends State> { }); popoverController = PopoverController(); - super.initState(); } @override @@ -197,21 +197,22 @@ class _RowCardState extends State> { } class _CardContent extends StatelessWidget { - final CardCellBuilder cellBuilder; - final EditableRowNotifier rowNotifier; - final List cells; - final RowCardRenderHook? renderHook; - final CustomCardData? cardData; - final RowCardStyleConfiguration styleConfiguration; const _CardContent({ + super.key, required this.rowNotifier, required this.cellBuilder, required this.cells, required this.cardData, required this.styleConfiguration, this.renderHook, - Key? key, - }) : super(key: key); + }); + + final CardCellBuilder cellBuilder; + final EditableRowNotifier rowNotifier; + final List cells; + final RowCardRenderHook? renderHook; + final CustomCardData? cardData; + final RowCardStyleConfiguration styleConfiguration; @override Widget build(BuildContext context) { @@ -244,31 +245,30 @@ class _CardContent extends StatelessWidget { // Remove all the cell listeners. rowNotifier.unbind(); - cells.asMap().forEach( - (int index, DatabaseCellContext cellContext) { - final isEditing = index == 0 ? rowNotifier.isEditing.value : false; - final cellNotifier = EditableCardNotifier(isEditing: isEditing); - - if (index == 0) { - // Only use the first cell to receive user's input when click the edit - // button - rowNotifier.bindCell(cellContext, cellNotifier); - } - - final child = Padding( - key: cellContext.key(), - padding: styleConfiguration.cellPadding, - child: cellBuilder.buildCell( - cellContext: cellContext, - cellNotifier: cellNotifier, - renderHook: renderHook, - cardData: cardData, - ), - ); + cells.asMap().forEach((int index, DatabaseCellContext cellContext) { + final isEditing = index == 0 ? rowNotifier.isEditing.value : false; + final cellNotifier = EditableCardNotifier(isEditing: isEditing); - children.add(child); - }, - ); + if (index == 0) { + // Only use the first cell to receive user's input when click the edit + // button + rowNotifier.bindCell(cellContext, cellNotifier); + } + + final child = Padding( + key: cellContext.key(), + padding: styleConfiguration.cellPadding, + child: cellBuilder.buildCell( + cellContext: cellContext, + cellNotifier: cellNotifier, + renderHook: renderHook, + cardData: cardData, + hasNotes: !cellContext.rowMeta.isDocumentEmpty, + ), + ); + + children.add(child); + }); return children; } } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card_bloc.dart index 296a94a66172..06e873b86582 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card_bloc.dart @@ -1,4 +1,5 @@ import 'dart:collection'; +import 'package:appflowy/plugins/database_view/application/row/row_listener.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -16,8 +17,10 @@ class CardBloc extends Bloc { final String? groupFieldId; final RowBackendService _rowBackendSvc; final RowCache _rowCache; - VoidCallback? _rowCallback; final String viewId; + final RowListener _rowListener; + + VoidCallback? _rowCallback; CardBloc({ required this.rowMeta, @@ -26,6 +29,7 @@ class CardBloc extends Bloc { required RowCache rowCache, required bool isEditing, }) : _rowBackendSvc = RowBackendService(viewId: viewId), + _rowListener = RowListener(rowMeta.id), _rowCache = rowCache, super( RowCardState.initial( @@ -50,6 +54,16 @@ class CardBloc extends Bloc { setIsEditing: (bool isEditing) { emit(state.copyWith(isEditing: isEditing)); }, + didReceiveRowMeta: (rowMeta) { + final cells = state.cells + .map( + (cell) => cell.rowMeta.id == rowMeta.id + ? cell.copyWith(rowMeta: rowMeta) + : cell, + ) + .toList(); + emit(state.copyWith(cells: cells)); + }, ); }, ); @@ -85,6 +99,14 @@ class CardBloc extends Bloc { } }, ); + + _rowListener.start( + onMetaChanged: (meta) { + if (!isClosed) { + add(RowCardEvent.didReceiveRowMeta(meta)); + } + }, + ); } } @@ -116,6 +138,9 @@ class RowCardEvent with _$RowCardEvent { List cells, ChangedReason reason, ) = _DidReceiveCells; + const factory RowCardEvent.didReceiveRowMeta( + RowMetaPB meta, + ) = _DidReceiveRowMeta; } @freezed diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card_cell_builder.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card_cell_builder.dart index 3ab828b1efa9..695244eb2eaa 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card_cell_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card_cell_builder.dart @@ -25,6 +25,7 @@ class CardCellBuilder { required DatabaseCellContext cellContext, EditableCardNotifier? cellNotifier, RowCardRenderHook? renderHook, + required bool hasNotes, }) { final cellControllerBuilder = CellControllerBuilder( cellContext: cellContext, @@ -86,12 +87,13 @@ class CardCellBuilder { ); case FieldType.RichText: return TextCardCell( + key: key, + style: isStyleOrNull(style), + cardData: cardData, renderHook: renderHook?.renderHook[FieldType.RichText], cellControllerBuilder: cellControllerBuilder, editableNotifier: cellNotifier, - cardData: cardData, - style: isStyleOrNull(style), - key: key, + showNotes: cellContext.fieldInfo.isPrimary && hasNotes, ); case FieldType.URL: return URLCardCell( diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/select_option_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/select_option_card_cell.dart index 7d9bfded9f95..bf199f5b7dc5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/select_option_card_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/select_option_card_cell.dart @@ -1,10 +1,7 @@ import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database_view/widgets/row/cells/select_option_cell/extension.dart'; import 'package:appflowy/plugins/database_view/widgets/row/cells/select_option_cell/select_option_cell_bloc.dart'; -import 'package:appflowy/plugins/database_view/widgets/row/cells/select_option_cell/select_option_editor.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -35,11 +32,9 @@ class SelectOptionCardCell class _SelectOptionCellState extends State { late SelectOptionCellBloc _cellBloc; - late PopoverController _popover; @override void initState() { - _popover = PopoverController(); final cellController = widget.cellControllerBuilder.build() as SelectOptionCellController; _cellBloc = SelectOptionCellBloc(cellController: cellController) @@ -65,16 +60,14 @@ class _SelectOptionCellState extends State { return custom; } - final children = state.selectedOptions.map( - (option) { - final tag = SelectOptionTag.fromOption( - context: context, - option: option, - onSelected: () => _popover.show(), - ); - return _wrapPopover(tag); - }, - ).toList(); + final children = state.selectedOptions + .map( + (option) => SelectOptionTag.fromOption( + context: context, + option: option, + ), + ) + .toList(); return IntrinsicHeight( child: Padding( @@ -89,28 +82,6 @@ class _SelectOptionCellState extends State { ); } - Widget _wrapPopover(Widget child) { - final constraints = BoxConstraints.loose( - Size( - SelectOptionCellEditor.editorPanelWidth, - 300, - ), - ); - return AppFlowyPopover( - controller: _popover, - constraints: constraints, - direction: PopoverDirection.bottomWithLeftAligned, - popupBuilder: (BuildContext context) { - return SelectOptionCellEditor( - cellController: widget.cellControllerBuilder.build() - as SelectOptionCellController, - ); - }, - onClose: () {}, - child: child, - ); - } - @override Future dispose() async { _cellBloc.close(); diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/text_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/text_card_cell.dart index f2f99be92817..7f23f0e3745f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/text_card_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/text_card_cell.dart @@ -1,6 +1,8 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database_view/widgets/row/cells/text_cell/text_cell_bloc.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../row/cell_builder.dart'; @@ -15,19 +17,21 @@ class TextCardCellStyle extends CardCellStyle { class TextCardCell extends CardCell with EditableCell { - @override - final EditableCardNotifier? editableNotifier; - final CellControllerBuilder cellControllerBuilder; - final CellRenderHook? renderHook; - const TextCardCell({ + super.key, + super.cardData, + super.style, required this.cellControllerBuilder, - required CustomCardData? cardData, this.editableNotifier, this.renderHook, - TextCardCellStyle? style, - Key? key, - }) : super(key: key, style: style, cardData: cardData); + this.showNotes = false, + }); + + @override + final EditableCardNotifier? editableNotifier; + final CellControllerBuilder cellControllerBuilder; + final CellRenderHook? renderHook; + final bool showNotes; @override State createState() => _TextCellState(); @@ -122,14 +126,19 @@ class _TextCellState extends State { return const SizedBox(); } - // - Widget child; - if (state.enableEdit || focusWhenInit) { - child = _buildTextField(); - } else { - child = _buildText(state); - } - return Align(alignment: Alignment.centerLeft, child: child); + final child = state.enableEdit || focusWhenInit + ? _buildTextField() + : _buildText(state); + + return Row( + children: [ + if (widget.showNotes) ...[ + const FlowySvg(FlowySvgs.notes_s), + const HSpace(4), + ], + Expanded(child: child), + ], + ); }, ), ), @@ -151,9 +160,9 @@ class _TextCellState extends State { double _fontSize() { if (widget.style != null) { return widget.style!.fontSize; - } else { - return 14; } + + return 14; } Widget _buildText(TextCellState state) { diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/group/database_group.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/group/database_group.dart index 066cf9f1b693..20699de28ff3 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/group/database_group.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/group/database_group.dart @@ -8,6 +8,7 @@ import 'package:appflowy/plugins/database_view/grid/presentation/widgets/common/ import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/board_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -18,6 +19,7 @@ import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:protobuf/protobuf.dart' hide FieldInfo; class DatabaseGroupList extends StatelessWidget { final String viewId; @@ -60,9 +62,9 @@ class DatabaseGroupList extends StatelessWidget { ), ), Toggle( - value: !state.hideUngrouped, + value: !state.layoutSettings.hideUngroupedColumn, onChanged: (value) => - databaseController.updateGroupConfiguration(value), + _updateLayoutSettings(state.layoutSettings, value), style: ToggleStyle.big, padding: EdgeInsets.zero, ), @@ -105,6 +107,19 @@ class DatabaseGroupList extends StatelessWidget { ), ); } + + Future _updateLayoutSettings( + BoardLayoutSettingPB layoutSettings, + bool hideUngrouped, + ) { + layoutSettings.freeze(); + final newLayoutSetting = layoutSettings.rebuild((message) { + message.hideUngroupedColumn = hideUngrouped; + }); + return databaseController.updateLayoutSetting( + boardLayoutSetting: newLayoutSetting, + ); + } } class _GridGroupCell extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/select_option_cell/extension.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/select_option_cell/extension.dart index 5b4ec4f037fd..4349616c4934 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/select_option_cell/extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/select_option_cell/extension.dart @@ -3,10 +3,8 @@ import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart' import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -93,7 +91,7 @@ class SelectOptionTag extends StatelessWidget { @override Widget build(BuildContext context) { EdgeInsets padding = - const EdgeInsets.symmetric(vertical: 1.5, horizontal: 8.0); + const EdgeInsets.symmetric(vertical: 2, horizontal: 8.0); if (onRemove != null) { padding = padding.copyWith(right: 2.0); } @@ -125,7 +123,7 @@ class SelectOptionTag extends StatelessWidget { FlowySvgs.close_s, ), ), - ] + ], ], ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/text_cell/text_cell.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/text_cell/text_cell.dart index 09a78f91fbfd..84de14129b21 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/text_cell/text_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/text_cell/text_cell.dart @@ -126,7 +126,7 @@ class _GridTextCellState extends GridEditableTextCell { isDense: true, ), ), - ) + ), ], ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_banner.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_banner.dart index 8a99d638ee67..8de53226930a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_banner.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_banner.dart @@ -186,10 +186,9 @@ class _BannerTitleState extends State<_BannerTitle> { controller: widget.popoverController, triggerActions: PopoverTriggerFlags.none, direction: PopoverDirection.bottomWithLeftAligned, + constraints: const BoxConstraints(maxWidth: 380, maxHeight: 300), popupBuilder: (popoverContext) => _buildEmojiPicker((emoji) { - context - .read() - .add(RowBannerEvent.setIcon(emoji.emoji)); + context.read().add(RowBannerEvent.setIcon(emoji)); widget.popoverController.close(); }), child: Row(children: children), @@ -199,7 +198,7 @@ class _BannerTitleState extends State<_BannerTitle> { } } -typedef OnSubmittedEmoji = void Function(Emoji emoji); +typedef OnSubmittedEmoji = void Function(String emoji); const _kBannerActionHeight = 40.0; class EmojiButton extends StatelessWidget { @@ -286,12 +285,9 @@ class RemoveEmojiButton extends StatelessWidget { } Widget _buildEmojiPicker(OnSubmittedEmoji onSubmitted) { - return SizedBox( - height: 250, - child: EmojiSelectionMenu( - onSubmitted: onSubmitted, - onExit: () {}, - ), + return EmojiSelectionMenu( + onSubmitted: onSubmitted, + onExit: () {}, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_detail.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_detail.dart index 4ab52f899e0b..c773fe5fc0e1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_detail.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_detail.dart @@ -15,10 +15,10 @@ class RowDetailPage extends StatefulWidget with FlowyOverlayDelegate { final GridCellBuilder cellBuilder; const RowDetailPage({ + super.key, required this.rowController, required this.cellBuilder, - Key? key, - }) : super(key: key); + }); @override State createState() => _RowDetailPageState(); diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_document.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_document.dart index 9aa9c2c10aae..6a529a3976d3 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_document.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_document.dart @@ -24,12 +24,8 @@ class RowDocument extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => RowDocumentBloc( - viewId: viewId, - rowId: rowId, - )..add( - const RowDocumentEvent.initial(), - ), + create: (context) => RowDocumentBloc(viewId: viewId, rowId: rowId) + ..add(const RowDocumentEvent.initial()), child: BlocBuilder( builder: (context, state) { return state.loadingState.when( @@ -43,6 +39,9 @@ class RowDocument extends StatelessWidget { finish: () => RowEditor( viewPB: state.viewPB!, scrollController: scrollController, + onIsEmptyChanged: (isEmpty) => context + .read() + .add(RowDocumentEvent.updateIsEmpty(isEmpty)), ), ); }, @@ -56,10 +55,12 @@ class RowEditor extends StatefulWidget { super.key, required this.viewPB, required this.scrollController, + this.onIsEmptyChanged, }); final ViewPB viewPB; final ScrollController scrollController; + final void Function(bool)? onIsEmptyChanged; @override State createState() => _RowEditorState(); @@ -87,43 +88,56 @@ class _RowEditorState extends State { providers: [ BlocProvider.value(value: documentBloc), ], - child: BlocBuilder( - builder: (context, state) { - return state.loadingState.when( - loading: () => const Center( - child: CircularProgressIndicator.adaptive(), - ), - finish: (result) { - return result.fold( - (error) => FlowyErrorPage.message( - error.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), - ), - (_) { - final editorState = documentBloc.editorState; - if (editorState == null) { - return const SizedBox.shrink(); - } - return IntrinsicHeight( - child: Container( - constraints: const BoxConstraints(minHeight: 300), - child: AppFlowyEditorPage( - shrinkWrap: true, - autoFocus: false, - editorState: editorState, - scrollController: widget.scrollController, - styleCustomizer: EditorStyleCustomizer( - context: context, - padding: const EdgeInsets.symmetric(horizontal: 10), + child: BlocListener( + listenWhen: (previous, current) => + previous.isDocumentEmpty != current.isDocumentEmpty, + listener: (context, state) { + if (state.isDocumentEmpty != null) { + widget.onIsEmptyChanged?.call(state.isDocumentEmpty!); + } + }, + child: BlocBuilder( + builder: (context, state) { + return state.loadingState.when( + loading: () => const Center( + child: CircularProgressIndicator.adaptive(), + ), + finish: (result) { + return result.fold( + (error) => FlowyErrorPage.message( + error.toString(), + howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + ), + (_) { + final editorState = documentBloc.editorState; + if (editorState == null) { + return const SizedBox.shrink(); + } + return IntrinsicHeight( + child: Container( + constraints: const BoxConstraints(minHeight: 300), + child: AppFlowyEditorPage( + shrinkWrap: true, + autoFocus: false, + editorState: editorState, + scrollController: widget.scrollController, + styleCustomizer: EditorStyleCustomizer( + context: context, + padding: const EdgeInsets.only(left: 16, right: 54), + ), + showParagraphPlaceholder: (editorState, node) => + editorState.document.isEmpty, + placeholderText: (node) => + LocaleKeys.cardDetails_notesPlaceholder.tr(), ), ), - ), - ); - }, - ); - }, - ); - }, + ); + }, + ); + }, + ); + }, + ), ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/setting/setting_button.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/setting/setting_button.dart index 5b809371eb45..160ff16db5fc 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/setting/setting_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/setting/setting_button.dart @@ -115,7 +115,9 @@ class ICalendarSettingImpl extends ICalendarSetting { @override void updateLayoutSettings(CalendarLayoutSettingPB layoutSettings) { - _databaseController.updateLayoutSetting(layoutSettings); + _databaseController.updateLayoutSetting( + calendarLayoutSetting: layoutSettings, + ); } @override diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/setting/setting_property_list.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/setting/setting_property_list.dart index 53e43ea4dc80..f2fb63970e75 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/setting/setting_property_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/setting/setting_property_list.dart @@ -6,7 +6,6 @@ import 'package:appflowy/plugins/database_view/application/field/field_info.dart import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart'; import 'package:appflowy/plugins/database_view/application/setting/property_bloc.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart'; -import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:collection/collection.dart'; @@ -40,9 +39,9 @@ class _DatabasePropertyListState extends State { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => getIt( - param1: widget.viewId, - param2: widget.fieldController, + create: (context) => DatabasePropertyBloc( + viewId: widget.viewId, + fieldController: widget.fieldController, )..add(const DatabasePropertyEvent.initial()), child: BlocBuilder( builder: (context, state) { diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/share_button.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/share_button.dart index 28ccc7fa6975..a334838c5cc4 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/share_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/share_button.dart @@ -104,7 +104,7 @@ class DatabaseShareActionListState extends State { buildChild: (controller) { return RoundedTextButton( title: LocaleKeys.shareAction_buttonText.tr(), - textColor: Theme.of(context).colorScheme.onSurface, + textColor: Theme.of(context).colorScheme.onPrimary, onPressed: () => controller.show(), ); }, diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart index 7ae081ba8aeb..a41057ae0c27 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart @@ -31,13 +31,7 @@ class DocumentBloc extends Bloc { required this.view, }) : _documentListener = DocumentListener(id: view.id), _viewListener = ViewListener(viewId: view.id), - _documentService = DocumentService(), - _trashService = TrashService(), super(DocumentState.initial()) { - _transactionAdapter = TransactionAdapter( - documentId: view.id, - documentService: _documentService, - ); on(_onDocumentEvent); } @@ -46,10 +40,13 @@ class DocumentBloc extends Bloc { final DocumentListener _documentListener; final ViewListener _viewListener; - final DocumentService _documentService; - final TrashService _trashService; + final DocumentService _documentService = DocumentService(); + final TrashService _trashService = TrashService(); - late final TransactionAdapter _transactionAdapter; + late final TransactionAdapter _transactionAdapter = TransactionAdapter( + documentId: view.id, + documentService: _documentService, + ); EditorState? editorState; StreamSubscription? _subscription; @@ -158,6 +155,11 @@ class DocumentBloc extends Bloc { // check if the document is empty. applyRules(); + + if (!isClosed) { + // ignore: invalid_use_of_visible_for_testing_member + emit(state.copyWith(isDocumentEmpty: editorState.document.isEmpty)); + } }); // output the log from the editor when debug mode @@ -240,6 +242,7 @@ class DocumentState with _$DocumentState { required DocumentLoadingState loadingState, required bool isDeleted, required bool forceClose, + bool? isDocumentEmpty, UserProfilePB? userProfilePB, }) = _DocumentState; @@ -247,6 +250,7 @@ class DocumentState with _$DocumentState { loadingState: _Loading(), isDeleted: false, forceClose: false, + isDocumentEmpty: null, userProfilePB: null, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/document.dart b/frontend/appflowy_flutter/lib/plugins/document/document.dart index 89c8f6bd4d85..0d3fb64e318e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document.dart @@ -9,10 +9,10 @@ import 'package:appflowy/plugins/document/presentation/share/share_button.dart'; import 'package:appflowy/plugins/util.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; -import 'package:appflowy/workspace/presentation/widgets/left_bar_item.dart'; import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart'; -import 'package:easy_localization/easy_localization.dart'; +import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -105,7 +105,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder } @override - Widget get leftBarItem => ViewLeftBarItem(view: view); + Widget get leftBarItem => ViewTitleBar(view: view); @override Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view); diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index 31f713aeb6e6..03e2800ed181 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -12,6 +12,7 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/base64_string.dart'; import 'package:appflowy/workspace/application/notifications/notification_action.dart'; import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart' @@ -25,6 +26,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:path/path.dart' as p; +enum EditorNotificationType { + undo, + redo, +} + +class EditorNotification extends Notification { + const EditorNotification({ + required this.type, + }); + + EditorNotification.undo() : type = EditorNotificationType.undo; + EditorNotification.redo() : type = EditorNotificationType.redo; + + final EditorNotificationType type; +} + class DocumentPage extends StatefulWidget { const DocumentPage({ super.key, @@ -96,7 +113,10 @@ class _DocumentPageState extends State { ); } else { editorState = documentBloc.editorState!; - return _buildEditorPage(context, state); + return _buildEditorPage( + context, + state, + ); } }, ), @@ -114,12 +134,11 @@ class _DocumentPageState extends State { styleCustomizer: EditorStyleCustomizer( context: context, // the 44 is the width of the left action list - padding: PlatformExtension.isMobile - ? const EdgeInsets.only(left: 20, right: 20) - : const EdgeInsets.only(left: 40, right: 40 + 44), + padding: EditorStyleCustomizer.documentPadding, ), header: _buildCoverAndIcon(context), ); + return Column( children: [ if (state.isDeleted) _buildBanner(context), @@ -143,6 +162,13 @@ class _DocumentPageState extends State { return DocumentHeaderNodeWidget( node: page, editorState: editorState!, + view: widget.view, + onIconChanged: (icon) async { + await ViewBackendService.updateViewIcon( + viewId: widget.view.id, + viewIcon: icon, + ); + }, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart index 5d0394291e1d..74c83cfdaf86 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; @@ -11,6 +12,8 @@ Map getEditorBuilderMap({ required EditorStyleCustomizer styleCustomizer, List? slashMenuItems, bool editable = true, + ShowPlaceholder? showParagraphPlaceholder, + String Function(Node)? placeholderText, }) { final standardActions = [ OptionAction.delete, @@ -24,12 +27,16 @@ Map getEditorBuilderMap({ final configuration = BlockComponentConfiguration( padding: (_) => const EdgeInsets.symmetric(vertical: 5.0), + indentPadding: (node, textDirection) => textDirection == TextDirection.ltr + ? const EdgeInsets.only(left: 26.0) + : const EdgeInsets.only(right: 26.0), ); final customBlockComponentBuilderMap = { PageBlockKeys.type: PageBlockComponentBuilder(), ParagraphBlockKeys.type: ParagraphBlockComponentBuilder( - configuration: configuration, + configuration: configuration.copyWith(placeholderText: placeholderText), + showPlaceholder: showParagraphPlaceholder, ), TodoListBlockKeys.type: TodoListBlockComponentBuilder( configuration: configuration.copyWith( @@ -116,11 +123,17 @@ Map getEditorBuilderMap({ DividerBlockKeys.type: DividerBlockComponentBuilder( configuration: configuration, height: 28.0, + wrapper: (context, node, child) { + return MobileBlockActionButtons( + showThreeDots: false, + node: node, + editorState: editorState, + child: child, + ); + }, ), MathEquationBlockKeys.type: MathEquationBlockComponentBuilder( - configuration: configuration.copyWith( - padding: (_) => const EdgeInsets.symmetric(vertical: 20), - ), + configuration: configuration, ), CodeBlockKeys.type: CodeBlockComponentBuilder( configuration: configuration.copyWith( @@ -145,9 +158,7 @@ Map getEditorBuilderMap({ ), ), errorBlockComponentBuilderKey: ErrorBlockComponentBuilder( - configuration: configuration.copyWith( - padding: (_) => const EdgeInsets.symmetric(vertical: 10), - ), + configuration: configuration, ), }; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index 5f63347c5ede..3148c5164c6a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -1,6 +1,7 @@ import 'package:appflowy/plugins/document/application/doc_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'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/i18n/editor_i18n.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/slash_menu_items.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; @@ -46,6 +47,8 @@ class AppFlowyEditorPage extends StatefulWidget { this.scrollController, this.autoFocus, required this.styleCustomizer, + this.showParagraphPlaceholder, + this.placeholderText, }); final Widget? header; @@ -54,6 +57,8 @@ class AppFlowyEditorPage extends StatefulWidget { final bool shrinkWrap; final bool? autoFocus; final EditorStyleCustomizer styleCustomizer; + final ShowPlaceholder? showParagraphPlaceholder; + final String Function(Node)? placeholderText; @override State createState() => _AppFlowyEditorPageState(); @@ -111,6 +116,8 @@ class _AppFlowyEditorPageState extends State { context: context, editorState: widget.editorState, styleCustomizer: widget.styleCustomizer, + showParagraphPlaceholder: widget.showParagraphPlaceholder, + placeholderText: widget.placeholderText, ); List get characterShortcutEvents => [ @@ -139,6 +146,21 @@ class _AppFlowyEditorPageState extends State { inlineActionsService, style: styleCustomizer.inlineActionsMenuStyleBuilder(), ), + + /// Inline page menu + /// - Using `[[` + pageReferenceShortcutBrackets( + context, + documentBloc.view.id, + styleCustomizer.inlineActionsMenuStyleBuilder(), + ), + + /// - Using `+` + pageReferenceShortcutPlusSign( + context, + documentBloc.view.id, + styleCustomizer.inlineActionsMenuStyleBuilder(), + ), ]; EditorStyleCustomizer get styleCustomizer => widget.styleCustomizer; @@ -229,7 +251,7 @@ class _AppFlowyEditorPageState extends State { contextMenuItems: customContextMenuItems, // customize the header and footer. header: widget.header, - footer: const VSpace(200), + footer: VSpace(PlatformExtension.isDesktopOrWeb ? 200 : 400), ), ); @@ -243,13 +265,17 @@ class _AppFlowyEditorPageState extends State { child: MobileFloatingToolbar( editorState: editorState, editorScrollController: editorScrollController, - toolbarBuilder: (context, anchor) { + toolbarBuilder: (context, anchor, closeToolbar) { return AdaptiveTextSelectionToolbar.editable( clipboardStatus: ClipboardStatus.pasteable, - onCopy: () => copyCommand.execute(editorState), + onCopy: () { + copyCommand.execute(editorState); + closeToolbar(); + }, onCut: () => cutCommand.execute(editorState), onPaste: () => pasteCommand.execute(editorState), onSelectAll: () => selectAllCommand.execute(editorState), + onLiveTextInput: null, anchors: TextSelectionToolbarAnchors( primaryAnchor: anchor, ), @@ -264,12 +290,17 @@ class _AppFlowyEditorPageState extends State { textDecorationMobileToolbarItem, buildTextAndBackgroundColorMobileToolbarItem(), headingMobileToolbarItem, - todoListMobileToolbarItem, - listMobileToolbarItem, + mobileBlocksToolbarItem, linkMobileToolbarItem, - quoteMobileToolbarItem, dividerMobileToolbarItem, + imageMobileToolbarItem, + mathEquationMobileToolbarItem, codeMobileToolbarItem, + mobileAlignToolbarItem, + mobileIndentToolbarItem, + mobileOutdentToolbarItem, + undoMobileToolbarItem, + redoMobileToolbarItem, ], ), ], @@ -322,6 +353,7 @@ class _AppFlowyEditorPageState extends State { referencedBoardMenuItem, inlineCalendarMenuItem(documentBloc), referencedCalendarMenuItem, + referencedDocumentMenuItem, calloutItem, outlineItem, mathEquationItem, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart index 8a262e671ccd..e4e1f954f8f3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart @@ -79,7 +79,7 @@ class BlockOptionButton extends StatelessWidget { ), TextSpan( text: LocaleKeys.document_plugins_optionAction_toOpenMenu.tr(), - ) + ), ], ), onTap: () { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart new file mode 100644 index 000000000000..6738e8d028cf --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart @@ -0,0 +1,117 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_block_action_widget.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// The ... button shows on the top right corner of a block. +/// +/// Default actions are: +/// - delete +/// - duplicate +/// - insert above +/// - insert below +/// +/// Only works on mobile. +class MobileBlockActionButtons extends StatelessWidget { + const MobileBlockActionButtons({ + super.key, + this.extendActionWidgets = const [], + this.showThreeDots = true, + required this.node, + required this.editorState, + required this.child, + }); + + final Node node; + final EditorState editorState; + final List extendActionWidgets; + final Widget child; + final bool showThreeDots; + + @override + Widget build(BuildContext context) { + if (!PlatformExtension.isMobile) { + return child; + } + + if (!showThreeDots) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _showBottomSheet(context), + child: child, + ); + } + + const padding = 10.0; + return Stack( + children: [ + child, + Positioned( + top: padding, + right: padding, + child: FlowyIconButton( + icon: const FlowySvg( + FlowySvgs.three_dots_s, + ), + width: 20.0, + onPressed: () => _showBottomSheet(context), + ), + ), + ], + ); + } + + void _showBottomSheet(BuildContext context) { + showFlowyMobileBottomSheet( + context, + title: LocaleKeys.document_plugins_action.tr(), + builder: (context) { + return BlockActionBottomSheet( + extendActionWidgets: extendActionWidgets, + onAction: (action) async { + context.pop(); + + final transaction = editorState.transaction; + switch (action) { + case BlockActionBottomSheetType.delete: + transaction.deleteNode(node); + break; + case BlockActionBottomSheetType.duplicate: + transaction.insertNode( + node.path.next, + node.copyWith(), + ); + break; + case BlockActionBottomSheetType.insertAbove: + case BlockActionBottomSheetType.insertBelow: + final path = action == BlockActionBottomSheetType.insertAbove + ? node.path + : node.path.next; + transaction + ..insertNode( + path, + paragraphNode(), + ) + ..afterSelection = Selection.collapsed( + Position( + path: path, + ), + ); + break; + default: + } + + if (transaction.operations.isNotEmpty) { + await editorState.apply(transaction); + } + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart index bd88f2fa8a6c..b2ac8d189b7b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart @@ -36,10 +36,13 @@ final alignToolbarItem = ToolbarItem( final child = MouseRegion( cursor: SystemMouseCursors.click, - child: FlowySvg( - data, - size: const Size.square(20), - color: isHighlight ? highlightColor : Colors.white, + child: FlowyTooltip( + message: LocaleKeys.document_plugins_optionAction_align.tr(), + child: FlowySvg( + data, + size: const Size.square(16), + color: isHighlight ? highlightColor : Colors.white, + ), ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart index 7ed483e5f7b5..579fb64e1b57 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart @@ -166,7 +166,7 @@ class _BuiltInPageWidgetState extends State { } controller.close(); }, - ) + ), ], ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart index 8b4bf37ffbcf..cc81c1e7f843 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart @@ -1,34 +1,75 @@ +import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; +import 'package:appflowy/plugins/base/icon/icon_picker.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; class EmojiPickerButton extends StatelessWidget { EmojiPickerButton({ super.key, required this.emoji, required this.onSubmitted, - this.emojiPickerSize = const Size(300, 250), + this.emojiPickerSize = const Size(360, 380), this.emojiSize = 18.0, + this.defaultIcon, + this.offset, + this.direction, }); final String emoji; final double emojiSize; final Size emojiPickerSize; - final void Function(Emoji emoji, PopoverController controller) onSubmitted; + final void Function(String emoji, PopoverController? controller) onSubmitted; final PopoverController popoverController = PopoverController(); + final Widget? defaultIcon; + final Offset? offset; + final PopoverDirection? direction; @override Widget build(BuildContext context) { - return AppFlowyPopover( - controller: popoverController, - triggerActions: PopoverTriggerFlags.click, - constraints: BoxConstraints.expand( - width: emojiPickerSize.width, - height: emojiPickerSize.height, - ), - popupBuilder: (context) => _buildEmojiPicker(), - child: FlowyTextButton( + if (PlatformExtension.isDesktopOrWeb) { + return AppFlowyPopover( + controller: popoverController, + triggerActions: PopoverTriggerFlags.click, + constraints: BoxConstraints.expand( + width: emojiPickerSize.width, + height: emojiPickerSize.height, + ), + offset: offset, + direction: direction ?? PopoverDirection.rightWithTopAligned, + popupBuilder: (context) => Container( + width: emojiPickerSize.width, + height: emojiPickerSize.height, + padding: const EdgeInsets.all(4.0), + child: EmojiSelectionMenu( + onSubmitted: (emoji) => onSubmitted(emoji, popoverController), + onExit: () {}, + ), + ), + child: emoji.isEmpty && defaultIcon != null + ? FlowyButton( + useIntrinsicWidth: true, + text: defaultIcon!, + onTap: () => popoverController.show(), + ) + : FlowyTextButton( + emoji, + overflow: TextOverflow.visible, + fontSize: emojiSize, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 35.0), + fillColor: Colors.transparent, + mainAxisAlignment: MainAxisAlignment.center, + onPressed: () { + popoverController.show(); + }, + ), + ); + } else { + return FlowyTextButton( emoji, overflow: TextOverflow.visible, fontSize: emojiSize, @@ -36,22 +77,18 @@ class EmojiPickerButton extends StatelessWidget { constraints: const BoxConstraints(minWidth: 35.0), fillColor: Colors.transparent, mainAxisAlignment: MainAxisAlignment.center, - onPressed: () { - popoverController.show(); + onPressed: () async { + final result = await context.push( + MobileEmojiPickerScreen.routeName, + ); + if (result != null) { + onSubmitted( + result.emoji, + null, + ); + } }, - ), - ); - } - - Widget _buildEmojiPicker() { - return Container( - width: emojiPickerSize.width, - height: emojiPickerSize.height, - padding: const EdgeInsets.all(4.0), - child: EmojiSelectionMenu( - onSubmitted: (emoji) => onSubmitted(emoji, popoverController), - onExit: () {}, - ), - ); + ); + } } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/insert_page_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/insert_page_command.dart index debf00f4e17c..759855e102d5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/insert_page_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/insert_page_command.dart @@ -1,5 +1,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database_view/application/database_view_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; @@ -34,6 +35,7 @@ extension InsertDatabase on EditorState { Future insertReferencePage( ViewPB childView, + ViewLayoutPB viewType, ) async { final selection = this.selection; if (selection == null || !selection.isCollapsed) { @@ -50,22 +52,63 @@ extension InsertDatabase on EditorState { ); } + late Transaction transaction; + if (viewType == ViewLayoutPB.Document) { + transaction = await _insertDocumentReference( + childView, + selection, + node, + ); + } else { + transaction = await _insertDatabaseReference( + childView, + selection.end.path, + ); + } + + await apply(transaction); + } + + Future _insertDocumentReference( + ViewPB view, + Selection selection, + Node node, + ) async { + return transaction + ..replaceText( + node, + selection.end.offset, + 0, + r'$', + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.page.name, + MentionBlockKeys.pageId: view.id, + }, + }, + ); + } + + Future _insertDatabaseReference( + ViewPB view, + List path, + ) async { // get the database id that the view is associated with - final databaseId = await DatabaseViewBackendService(viewId: childView.id) + final databaseId = await DatabaseViewBackendService(viewId: view.id) .getDatabaseId() .then((value) => value.swap().toOption().toNullable()); if (databaseId == null) { throw StateError( - 'The database associated with ${childView.id} could not be found while attempting to create a referenced ${childView.layout.name}.', + 'The database associated with ${view.id} could not be found while attempting to create a referenced ${view.layout.name}.', ); } - final prefix = _referencedDatabasePrefix(childView.layout); + final prefix = _referencedDatabasePrefix(view.layout); final ref = await ViewBackendService.createDatabaseLinkedView( - parentViewId: childView.id, - name: "$prefix ${childView.name}", - layoutType: childView.layout, + parentViewId: view.id, + name: "$prefix ${view.name}", + layoutType: view.layout, databaseId: databaseId, ).then((value) => value.swap().toOption().toNullable()); @@ -76,18 +119,17 @@ extension InsertDatabase on EditorState { ); } - final transaction = this.transaction; - transaction.insertNode( - selection.end.path, - Node( - type: _convertPageType(childView), - attributes: { - DatabaseBlockKeys.parentID: childView.id, - DatabaseBlockKeys.viewID: ref.id, - }, - ), - ); - await apply(transaction); + return transaction + ..insertNode( + path, + Node( + type: _convertPageType(view), + attributes: { + DatabaseBlockKeys.parentID: view.id, + DatabaseBlockKeys.viewID: ref.id, + }, + ), + ); } String _referencedDatabasePrefix(ViewLayoutPB layout) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart index d670f698010e..fcc971665fcf 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart @@ -1,4 +1,3 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/insert_page_command.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; @@ -24,8 +23,8 @@ void showLinkToPageMenu( final alignment = menuService.alignment; final offset = menuService.offset; - final top = alignment == Alignment.bottomLeft ? offset.dy : null; - final bottom = alignment == Alignment.topLeft ? offset.dy : null; + final top = alignment == Alignment.topLeft ? offset.dy : null; + final bottom = alignment == Alignment.bottomLeft ? offset.dy : null; keepEditorFocusNotifier.increase(); late OverlayEntry linkToPageMenuEntry; @@ -42,16 +41,18 @@ void showLinkToPageMenu( hintText: pageType.toHintText(), onSelected: (appPB, viewPB) async { try { - await editorState.insertReferencePage(viewPB); + await editorState.insertReferencePage(viewPB, pageType); linkToPageMenuEntry.remove(); } on FlowyError catch (e) { - Dialogs.show( - child: FlowyErrorPage.message( - e.msg, - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), - ), - context, - ); + if (context.mounted) { + Dialogs.show( + child: FlowyErrorPage.message( + e.msg, + howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + ), + context, + ); + } } }, ), @@ -150,7 +151,7 @@ class _LinkToPageMenuState extends State { LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.tab, - LogicalKeyboardKey.enter + LogicalKeyboardKey.enter, ]; if (!acceptedKeys.contains(event.logicalKey)) { @@ -188,6 +189,7 @@ class _LinkToPageMenuState extends State { ) { int index = 0; return FutureBuilder>( + future: items, builder: (context, snapshot) { if (snapshot.hasData && snapshot.connectionState == ConnectionState.done) { @@ -208,10 +210,7 @@ class _LinkToPageMenuState extends State { children.add( FlowyButton( isSelected: index == _selectedIndex, - leftIcon: FlowySvg( - view.iconData, - color: Theme.of(context).iconTheme.color, - ), + leftIcon: view.defaultIcon(), text: FlowyText.regular(view.name), onTap: () => widget.onSelected(view, view), ), @@ -229,7 +228,6 @@ class _LinkToPageMenuState extends State { return const Center(child: CircularProgressIndicator()); }, - future: items, ); } } @@ -239,14 +237,14 @@ extension on ViewLayoutPB { switch (this) { case ViewLayoutPB.Grid: return LocaleKeys.document_slashMenu_grid_selectAGridToLinkTo.tr(); - case ViewLayoutPB.Board: return LocaleKeys.document_slashMenu_board_selectABoardToLinkTo.tr(); - case ViewLayoutPB.Calendar: return LocaleKeys.document_slashMenu_calendar_selectACalendarToLinkTo .tr(); - + case ViewLayoutPB.Document: + return LocaleKeys.document_slashMenu_document_selectADocumentToLinkTo + .tr(); default: throw Exception('Unknown layout type'); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart new file mode 100644 index 000000000000..0df49665a02d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart @@ -0,0 +1,117 @@ +import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +const _bracketChar = '['; +const _plusChar = '+'; + +CharacterShortcutEvent pageReferenceShortcutBrackets( + BuildContext context, + String viewId, + InlineActionsMenuStyle style, +) => + CharacterShortcutEvent( + key: 'show the inline page reference menu by [', + character: _bracketChar, + handler: (editorState) => inlinePageReferenceCommandHandler( + _bracketChar, + context, + viewId, + editorState, + style, + previousChar: _bracketChar, + ), + ); + +CharacterShortcutEvent pageReferenceShortcutPlusSign( + BuildContext context, + String viewId, + InlineActionsMenuStyle style, +) => + CharacterShortcutEvent( + key: 'show the inline page reference menu by +', + character: _plusChar, + handler: (editorState) => inlinePageReferenceCommandHandler( + _plusChar, + context, + viewId, + editorState, + style, + ), + ); + +InlineActionsMenuService? selectionMenuService; +Future inlinePageReferenceCommandHandler( + String character, + BuildContext context, + String currentViewId, + EditorState editorState, + InlineActionsMenuStyle style, { + String? previousChar, +}) async { + final selection = editorState.selection; + if (PlatformExtension.isMobile || selection == null) { + return false; + } + + if (!selection.isCollapsed) { + await editorState.deleteSelection(selection); + } + + // Check for previous character + if (previousChar != null) { + final node = editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null || delta.isEmpty) { + return false; + } + + if (selection.end.offset > 0) { + final plain = delta.toPlainText(); + + final previousCharacter = plain[selection.end.offset - 1]; + if (previousCharacter != _bracketChar) { + return false; + } + } + } + + // ignore: use_build_context_synchronously + final service = InlineActionsService( + context: context, + handlers: [ + InlinePageReferenceService( + currentViewId: currentViewId, + ).inlinePageReferenceDelegate, + ], + ); + + await editorState.insertTextAtPosition(character, position: selection.start); + + final List initialResults = []; + for (final handler in service.handlers) { + final group = await handler(); + + if (group.results.isNotEmpty) { + initialResults.add(group); + } + } + + if (service.context != null) { + selectionMenuService = InlineActionsMenu( + context: service.context!, + editorState: editorState, + service: service, + initialResults: initialResults, + style: style, + startCharAmount: previousChar != null ? 2 : 1, + ); + + selectionMenuService?.show(); + } + + return true; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart index 3d67ac106bfd..b8e48c8c300f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart @@ -186,8 +186,8 @@ class _CalloutBlockComponentWidgetState ), // force to refresh the popover state emoji: emoji, onSubmitted: (emoji, controller) { - setEmoji(emoji.emoji); - controller.close(); + setEmoji(emoji); + controller?.close(); }, ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart index 27d07450930b..a30cf4451f99 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart @@ -74,6 +74,12 @@ class ClipboardService { await ClipboardWriter.instance.write([item]); } + Future setPlainText(String text) async { + await ClipboardWriter.instance.write([ + DataWriterItem()..add(Formats.plainText(text)), + ]); + } + Future getData() async { final reader = await ClipboardReader.readClipboard(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart index 369256eb84b9..a925182a3613 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart @@ -119,7 +119,7 @@ extension PasteNodes on EditorState { if (nodes.last.children.isNotEmpty) { return [ ...path, - ...calculatePath([0], nodes.last.children.toList()) + ...calculatePath([0], nodes.last.children.toList()), ]; } return path; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/referenced_database_menu_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/referenced_database_menu_item.dart index 94d20d479c66..976a0186dd5e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/referenced_database_menu_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/referenced_database_menu_item.dart @@ -7,6 +7,28 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +// Document Reference + +SelectionMenuItem referencedDocumentMenuItem = SelectionMenuItem( + name: LocaleKeys.document_plugins_referencedDocument.tr(), + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.documents_s, + isSelected: onSelected, + style: style, + ), + keywords: ['page', 'notes', 'referenced page', 'referenced document'], + handler: (editorState, menuService, context) { + showLinkToPageMenu( + Overlay.of(context), + editorState, + menuService, + ViewLayoutPB.Document, + ); + }, +); + +// Database References + SelectionMenuItem referencedGridMenuItem = SelectionMenuItem( name: LocaleKeys.document_plugins_referencedGrid.tr(), icon: (editorState, onSelected, style) => SelectableSvgWidget( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart index a50895d298e3..914a4bef572c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; @@ -8,6 +9,7 @@ import 'package:appflowy_editor/appflowy_editor.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'; class ErrorBlockComponentBuilder extends BlockComponentBuilder { ErrorBlockComponentBuilder({ @@ -72,11 +74,15 @@ class _DividerBlockComponentWidgetState extends State ClipboardServiceData(plainText: jsonEncode(node.toJson())), ); }, - text: Container( - height: 48, - alignment: Alignment.center, - child: FlowyText( - LocaleKeys.document_errorBlock_theBlockIsNotSupported.tr(), + text: SizedBox( + height: 52, + child: Row( + children: [ + const HSpace(4), + FlowyText( + LocaleKeys.document_errorBlock_theBlockIsNotSupported.tr(), + ), + ], ), ), ), @@ -95,6 +101,14 @@ class _DividerBlockComponentWidgetState extends State ); } + if (PlatformExtension.isMobile) { + child = MobileBlockActionButtons( + node: node, + editorState: context.read(), + child: child, + ); + } + return child; } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart index ea041a6cdd87..3c7fae3aa0c1 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart @@ -1,8 +1,11 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; final customizeFontToolbarItem = ToolbarItem( @@ -34,12 +37,15 @@ final customizeFontToolbarItem = ToolbarItem( onResetFont: () async => await editorState.formatDelta(selection, { AppFlowyRichTextKeys.fontFamily: null, }), - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 4.0), - child: FlowySvg( - FlowySvgs.font_family_s, - size: Size.square(16.0), - color: Colors.white, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: FlowyTooltip( + message: LocaleKeys.document_plugins_fonts.tr(), + child: const FlowySvg( + FlowySvgs.font_family_s, + size: Size.square(16.0), + color: Colors.white, + ), ), ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor.dart index 5e17a753fda1..ea3011205071 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor.dart @@ -19,7 +19,7 @@ const String kLocalImagesKey = 'local_images'; List get builtInAssetImages => [ "assets/images/app_flowy_abstract_cover_1.jpg", - "assets/images/app_flowy_abstract_cover_2.jpg" + "assets/images/app_flowy_abstract_cover_2.jpg", ]; class ChangeCoverPopover extends StatefulWidget { @@ -529,14 +529,12 @@ class ColorItem extends StatelessWidget { @override Widget build(BuildContext context) { - return InkWell( - customBorder: const RoundedRectangleBorder( - borderRadius: Corners.s6Border, - ), - hoverColor: hoverColor, - onTap: () => onTap(option.colorHex), - child: Padding( - padding: const EdgeInsets.only(right: 10.0), + return Padding( + padding: const EdgeInsets.only(right: 10.0), + child: InkWell( + customBorder: const CircleBorder(), + hoverColor: hoverColor, + onTap: () => onTap(option.colorHex), child: SizedBox.square( dimension: 25, child: DecoratedBox( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor_bloc.dart index 12a64a889c9c..c6c6fcc6e395 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor_bloc.dart @@ -102,7 +102,7 @@ class ChangeCoverPopoverBloc transaction.updateNode(node, { DocumentHeaderBlockKeys.coverType: CoverType.none.toString(), DocumentHeaderBlockKeys.icon: - node.attributes[DocumentHeaderBlockKeys.icon] + node.attributes[DocumentHeaderBlockKeys.icon], }); return editorState.apply(transaction); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart index 83d39b3bef1c..3dade8ae1a99 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart @@ -148,7 +148,7 @@ class _NetworkImageUrlInputState extends State { title: LocaleKeys.document_plugins_cover_add.tr(), borderRadius: Corners.s8Border, ), - ) + ), ], ); } @@ -322,7 +322,7 @@ class _CoverImagePreviewWidgetState extends State { (l) => _buildImageDeleteButton(context), (r) => Container(), ) - : Container() + : Container(), ], ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart index c958aac8022a..e18e74de6bbc 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart @@ -2,17 +2,25 @@ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; +import 'package:appflowy/plugins/base/icon/icon_picker.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu; import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:string_validator/string_validator.dart'; import 'cover_editor.dart'; -import 'emoji_icon_widget.dart'; -import 'emoji_popover.dart'; const double kCoverHeight = 250.0; const double kIconHeight = 60.0; @@ -45,13 +53,17 @@ enum CoverType { class DocumentHeaderNodeWidget extends StatefulWidget { const DocumentHeaderNodeWidget({ + super.key, required this.node, required this.editorState, - super.key, + required this.onIconChanged, + required this.view, }); final Node node; final EditorState editorState; + final void Function(String icon) onIconChanged; + final ViewPB view; @override State createState() => @@ -64,19 +76,33 @@ class _DocumentHeaderNodeWidgetState extends State { ); String? get coverDetails => widget.node.attributes[DocumentHeaderBlockKeys.coverDetails]; - String get icon => widget.node.attributes[DocumentHeaderBlockKeys.icon]; - bool get hasIcon => - widget.node.attributes[DocumentHeaderBlockKeys.icon]?.isNotEmpty ?? false; + String? get icon => widget.node.attributes[DocumentHeaderBlockKeys.icon]; + bool get hasIcon => viewIcon.isNotEmpty; bool get hasCover => coverType != CoverType.none; + String viewIcon = ''; + late final ViewListener viewListener; + @override void initState() { super.initState(); + final value = widget.view.icon.value; + viewIcon = value.isNotEmpty ? value : icon ?? ''; widget.node.addListener(_reload); + viewListener = ViewListener( + viewId: widget.view.id, + )..start( + onViewUpdated: (p0) { + setState(() { + viewIcon = p0.icon.value; + }); + }, + ); } @override void dispose() { + viewListener.stop(); widget.node.removeListener(_reload); super.dispose(); } @@ -108,7 +134,7 @@ class _DocumentHeaderNodeWidgetState extends State { ), if (hasIcon) Positioned( - left: 80, + left: PlatformExtension.isDesktopOrWeb ? 80 : 20, // if hasCover, there shouldn't be icons present so the icon can // be closer to the bottom. bottom: @@ -116,8 +142,10 @@ class _DocumentHeaderNodeWidgetState extends State { child: DocumentIcon( editorState: widget.editorState, node: widget.node, - icon: icon, - onIconChanged: (icon) => _saveCover(icon: icon), + icon: viewIcon, + onIconChanged: (icon) async { + _saveCover(icon: icon); + }, ), ), ], @@ -145,7 +173,7 @@ class _DocumentHeaderNodeWidgetState extends State { DocumentHeaderBlockKeys.coverDetails: widget.node.attributes[DocumentHeaderBlockKeys.coverDetails], DocumentHeaderBlockKeys.icon: - widget.node.attributes[DocumentHeaderBlockKeys.icon] + widget.node.attributes[DocumentHeaderBlockKeys.icon], }; if (cover != null) { attributes[DocumentHeaderBlockKeys.coverType] = cover.$1.toString(); @@ -153,6 +181,7 @@ class _DocumentHeaderNodeWidgetState extends State { } if (icon != null) { attributes[DocumentHeaderBlockKeys.icon] = icon; + widget.onIconChanged(icon); } transaction.updateNode(widget.node, attributes); @@ -188,29 +217,42 @@ class _DocumentHeaderToolbarState extends State { final PopoverController _popoverController = PopoverController(); + @override + void initState() { + super.initState(); + + isHidden = PlatformExtension.isDesktopOrWeb; + } + @override Widget build(BuildContext context) { - return MouseRegion( - onEnter: (event) => setHidden(false), - onExit: (event) { - if (!isPopoverOpen) { - setHidden(true); - } - }, - opaque: false, - child: Container( - alignment: Alignment.bottomLeft, - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 40), - child: SizedBox( - height: 28, - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: buildRowChildren(), - ), + Widget child = Container( + alignment: Alignment.bottomLeft, + width: double.infinity, + padding: EditorStyleCustomizer.documentPadding, + child: SizedBox( + height: 28, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: buildRowChildren(), ), ), ); + + if (PlatformExtension.isDesktopOrWeb) { + child = MouseRegion( + onEnter: (event) => setHidden(false), + onExit: (event) { + if (!isPopoverOpen) { + setHidden(true); + } + }, + opaque: false, + child: child, + ); + } + + return child; } List buildRowChildren() { @@ -224,7 +266,9 @@ class _DocumentHeaderToolbarState extends State { FlowyButton( leftIconSize: const Size.square(18), onTap: () => widget.onCoverChanged( - cover: (CoverType.asset, builtInAssetImages.first), + cover: PlatformExtension.isDesktopOrWeb + ? (CoverType.asset, builtInAssetImages.first) + : (CoverType.color, '0xffe8e0ff'), ), useIntrinsicWidth: true, leftIcon: const FlowySvg(FlowySvgs.image_s), @@ -251,42 +295,49 @@ class _DocumentHeaderToolbarState extends State { ), ); } else { - children.add( - AppFlowyPopover( + Widget child = FlowyButton( + leftIconSize: const Size.square(18), + useIntrinsicWidth: true, + leftIcon: const Icon( + Icons.emoji_emotions_outlined, + size: 18, + ), + text: FlowyText.regular( + LocaleKeys.document_plugins_cover_addIcon.tr(), + ), + onTap: PlatformExtension.isDesktop + ? null + : () async { + final result = await context.push( + MobileEmojiPickerScreen.routeName, + ); + if (result != null) { + widget.onCoverChanged(icon: result.emoji); + } + }, + ); + + if (PlatformExtension.isDesktop) { + child = AppFlowyPopover( onClose: () => isPopoverOpen = false, controller: _popoverController, offset: const Offset(0, 8), direction: PopoverDirection.bottomWithCenterAligned, - constraints: BoxConstraints.loose(const Size(300, 250)), - child: FlowyButton( - leftIconSize: const Size.square(18), - useIntrinsicWidth: true, - leftIcon: const Icon( - Icons.emoji_emotions_outlined, - size: 18, - ), - text: FlowyText.regular( - LocaleKeys.document_plugins_cover_addIcon.tr(), - ), - ), + constraints: BoxConstraints.loose(const Size(360, 380)), + child: child, popupBuilder: (BuildContext popoverContext) { isPopoverOpen = true; - return EmojiPopover( - showRemoveButton: widget.hasIcon, - removeIcon: () { - widget.onCoverChanged(icon: ""); - _popoverController.close(); - }, - node: widget.node, - editorState: widget.editorState, - onEmojiChanged: (Emoji emoji) { - widget.onCoverChanged(icon: emoji.emoji); + return FlowyIconPicker( + onSelected: (result) { + widget.onCoverChanged(icon: result.emoji); _popoverController.close(); }, ); }, - ), - ); + ); + } + + children.add(child); } return children; @@ -328,6 +379,12 @@ class DocumentCoverState extends State { @override Widget build(BuildContext context) { + return PlatformExtension.isDesktopOrWeb + ? _buildDesktopCover() + : _buildMobileCover(); + } + + Widget _buildDesktopCover() { return SizedBox( height: kCoverHeight, child: MouseRegion( @@ -341,17 +398,89 @@ class DocumentCoverState extends State { width: double.infinity, child: _buildCoverImage(), ), - if (!isOverlayButtonsHidden) _buildCoverOverlayButtons(context) + if (!isOverlayButtonsHidden) _buildCoverOverlayButtons(context), ], ), ), ); } + Widget _buildMobileCover() { + return SizedBox( + height: kCoverHeight, + child: Stack( + children: [ + SizedBox( + height: double.infinity, + width: double.infinity, + child: _buildCoverImage(), + ), + Positioned( + bottom: 8, + right: 12, + child: RoundedTextButton( + onPressed: () { + showFlowyMobileBottomSheet( + context, + title: LocaleKeys.document_plugins_cover_changeCover.tr(), + builder: (context) { + return ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 340, + minHeight: 80, + ), + child: UploadImageMenu( + supportTypes: const [ + UploadImageType.color, + UploadImageType.local, + UploadImageType.url, + UploadImageType.unsplash, + ], + onSelectedLocalImage: (path) async { + context.pop(); + widget.onCoverChanged(CoverType.file, path); + }, + onSelectedAIImage: (_) { + throw UnimplementedError(); + }, + onSelectedNetworkImage: (url) async { + context.pop(); + widget.onCoverChanged(CoverType.file, url); + }, + onSelectedColor: (color) { + context.pop(); + widget.onCoverChanged(CoverType.color, color); + }, + ), + ); + }, + ); + }, + fillColor: Theme.of(context).colorScheme.onSurfaceVariant, + width: 120, + height: 32, + title: LocaleKeys.document_plugins_cover_changeCover.tr(), + ), + ), + ], + ), + ); + } + Widget _buildCoverImage() { + final detail = widget.coverDetails; + if (detail == null) { + return const SizedBox.shrink(); + } switch (widget.coverType) { case CoverType.file: - final imageFile = File(widget.coverDetails ?? ""); + if (isURL(detail)) { + return CachedNetworkImage( + imageUrl: detail, + fit: BoxFit.cover, + ); + } + final imageFile = File(detail); if (!imageFile.existsSync()) { WidgetsBinding.instance.addPostFrameCallback((_) { widget.onCoverChanged(CoverType.none, null); @@ -471,27 +600,40 @@ class _DocumentIconState extends State { @override Widget build(BuildContext context) { - return AppFlowyPopover( - direction: PopoverDirection.bottomWithCenterAligned, - controller: _popoverController, - offset: const Offset(0, 8), - constraints: BoxConstraints.loose(const Size(320, 380)), - child: EmojiIconWidget(emoji: widget.icon), - popupBuilder: (BuildContext popoverContext) { - return EmojiPopover( - node: widget.node, - showRemoveButton: true, - removeIcon: () { - widget.onIconChanged(""); - _popoverController.close(); - }, - editorState: widget.editorState, - onEmojiChanged: (Emoji emoji) { - widget.onIconChanged(emoji.emoji); - _popoverController.close(); - }, - ); - }, + Widget child = EmojiIconWidget( + emoji: widget.icon, ); + + if (PlatformExtension.isDesktopOrWeb) { + child = AppFlowyPopover( + direction: PopoverDirection.bottomWithCenterAligned, + controller: _popoverController, + offset: const Offset(0, 8), + constraints: BoxConstraints.loose(const Size(360, 380)), + child: child, + popupBuilder: (BuildContext popoverContext) { + return FlowyIconPicker( + onSelected: (result) { + widget.onIconChanged(result.emoji); + _popoverController.close(); + }, + ); + }, + ); + } else { + child = GestureDetector( + child: child, + onTap: () async { + final result = await context.push( + MobileEmojiPickerScreen.routeName, + ); + if (result != null) { + widget.onIconChanged(result.emoji); + } + }, + ); + } + + return child; } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_popover.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_popover.dart deleted file mode 100644 index 4dfd9ece382b..000000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_popover.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; - -import 'package:flutter/material.dart'; - -/// Add icon menu in Header -class EmojiPopover extends StatefulWidget { - final EditorState editorState; - final Node node; - final void Function(Emoji emoji) onEmojiChanged; - final VoidCallback removeIcon; - final bool showRemoveButton; - - const EmojiPopover({ - super.key, - required this.editorState, - required this.node, - required this.onEmojiChanged, - required this.removeIcon, - required this.showRemoveButton, - }); - - @override - State createState() => _EmojiPopoverState(); -} - -class _EmojiPopoverState extends State { - @override - Widget build(BuildContext context) { - return Column( - children: [ - if (widget.showRemoveButton) - Padding( - padding: const EdgeInsets.only(bottom: 4.0), - child: Align( - alignment: Alignment.centerRight, - child: DeleteButton(onTap: widget.removeIcon), - ), - ), - Expanded( - child: EmojiPicker( - onEmojiSelected: (category, emoji) { - widget.onEmojiChanged(emoji); - }, - config: buildFlowyEmojiPickerConfig(context), - ), - ), - ], - ); - } -} - -class DeleteButton extends StatelessWidget { - final VoidCallback onTap; - const DeleteButton({required this.onTap, Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 28, - child: FlowyButton( - onTap: onTap, - useIntrinsicWidth: true, - text: FlowyText( - LocaleKeys.document_plugins_cover_removeIcon.tr(), - ), - leftIcon: const FlowySvg(FlowySvgs.delete_s), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart index 0e3537179267..1112343de8fd 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart @@ -1,7 +1,24 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart'; +import 'package:image_gallery_saver/image_gallery_saver.dart'; import 'package:provider/provider.dart'; +import 'package:string_validator/string_validator.dart'; const kImagePlaceholderKey = 'imagePlaceholderKey'; @@ -96,25 +113,30 @@ class CustomImageBlockComponentState extends State final height = attributes[ImageBlockKeys.height]?.toDouble(); final imagePlaceholderKey = node.extraInfos?[kImagePlaceholderKey]; - Widget child = src.isEmpty - ? ImagePlaceholder( - key: imagePlaceholderKey is GlobalKey ? imagePlaceholderKey : null, - node: node, - ) - : ResizableImage( - src: src, - width: width, - height: height, - editable: editorState.editable, - alignment: alignment, - onResize: (width) { - final transaction = editorState.transaction - ..updateNode(node, { - ImageBlockKeys.width: width, - }); - editorState.apply(transaction); - }, - ); + Widget child; + if (src.isEmpty) { + child = ImagePlaceholder( + key: imagePlaceholderKey is GlobalKey ? imagePlaceholderKey : null, + node: node, + ); + } else if (!_checkIfURLIsValid(src)) { + child = const UnSupportImageWidget(); + } else { + child = ResizableImage( + src: src, + width: width, + height: height, + editable: editorState.editable, + alignment: alignment, + onResize: (width) { + final transaction = editorState.transaction + ..updateNode(node, { + ImageBlockKeys.width: width, + }); + editorState.apply(transaction); + }, + ); + } child = BlockSelectionContainer( node: node, @@ -139,40 +161,51 @@ class CustomImageBlockComponentState extends State ); } - if (widget.showMenu && widget.menuBuilder != null) { - child = MouseRegion( - onEnter: (_) => showActionsNotifier.value = true, - onExit: (_) { - if (!alwaysShowMenu) { - showActionsNotifier.value = false; - } - }, - hitTestBehavior: HitTestBehavior.opaque, - opaque: false, - child: ValueListenableBuilder( - valueListenable: showActionsNotifier, - builder: (context, value, child) { - final url = node.attributes[ImageBlockKeys.url]; - return Stack( - children: [ - BlockSelectionContainer( - node: node, - delegate: this, - listenable: editorState.selectionNotifier, - cursorColor: editorState.editorStyle.cursorColor, - selectionColor: editorState.editorStyle.selectionColor, - child: child!, - ), - if (value && url.isNotEmpty == true) - widget.menuBuilder!( - widget.node, - this, - ), - ], - ); + // show a hover menu on desktop or web + if (PlatformExtension.isDesktopOrWeb) { + if (widget.showMenu && widget.menuBuilder != null) { + child = MouseRegion( + onEnter: (_) => showActionsNotifier.value = true, + onExit: (_) { + if (!alwaysShowMenu) { + showActionsNotifier.value = false; + } }, - child: child, - ), + hitTestBehavior: HitTestBehavior.opaque, + opaque: false, + child: ValueListenableBuilder( + valueListenable: showActionsNotifier, + builder: (context, value, child) { + final url = node.attributes[ImageBlockKeys.url]; + return Stack( + children: [ + BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + cursorColor: editorState.editorStyle.cursorColor, + selectionColor: editorState.editorStyle.selectionColor, + child: child!, + ), + if (value && url.isNotEmpty == true) + widget.menuBuilder!( + widget.node, + this, + ), + ], + ); + }, + child: child, + ), + ); + } + } else { + // show a fixed menu on mobile + child = MobileBlockActionButtons( + node: node, + editorState: editorState, + extendActionWidgets: _buildExtendActionWidgets(context), + child: child, ); } @@ -246,4 +279,89 @@ class CustomImageBlockComponentState extends State bool shiftWithBaseOffset = false, }) => _renderBox!.localToGlobal(offset); + + // only used on mobile platform + List _buildExtendActionWidgets(BuildContext context) { + final url = widget.node.attributes[ImageBlockKeys.url]; + if (!_checkIfURLIsValid(url)) { + return []; + } + + return [ + Row( + children: [ + Expanded( + child: BottomSheetActionWidget( + svg: FlowySvgs.copy_s, + text: LocaleKeys.editor_copyLink.tr(), + onTap: () async { + context.pop(); + showSnackBarMessage( + context, + LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), + ); + await getIt().setPlainText(url); + }, + ), + ), + const HSpace(8.0), + Expanded( + child: BottomSheetActionWidget( + svg: FlowySvgs.image_placeholder_s, + text: LocaleKeys.document_imageBlock_saveImageToGallery.tr(), + onTap: () async { + context.pop(); + Uint8List? bytes; + if (isURL(url)) { + // network image + final result = await get(Uri.parse(url)); + if (result.statusCode == 200) { + bytes = result.bodyBytes; + } + } else { + final file = File(url); + bytes = await file.readAsBytes(); + } + if (bytes != null) { + await ImageGallerySaver.saveImage(bytes); + if (context.mounted) { + showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_successToAddImageToGallery + .tr(), + ); + } + } else { + if (context.mounted) { + showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_failedToAddImageToGallery + .tr(), + ); + } + } + }, + ), + ), + ], + ), + const VSpace(8), + ]; + } + + bool _checkIfURLIsValid(dynamic url) { + if (url is! String) { + return false; + } + + if (url.isEmpty) { + return false; + } + + if (!isURL(url) && !File(url).existsSync()) { + return false; + } + + return true; + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart index 5420d949db2a..04e33fbbc525 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart @@ -32,6 +32,7 @@ class _EmbedImageUrlWidgetState extends State { SizedBox( width: 160, child: FlowyButton( + showDefaultBoxDecorationOnMobile: true, margin: const EdgeInsets.all(8.0), text: FlowyText( LocaleKeys.document_imageBlock_embedLink_label.tr(), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart new file mode 100644 index 000000000000..a1f4487562a1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart @@ -0,0 +1,41 @@ +import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class ImagePickerPage extends StatefulWidget { + const ImagePickerPage({ + super.key, + // required this.onSelected, + }); + + // final void Function(EmojiPickerResult) onSelected; + + @override + State createState() => _ImagePickerPageState(); +} + +class _ImagePickerPageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + titleSpacing: 0, + title: const FlowyText.semibold( + 'Page icon', + fontSize: 14.0, + ), + leading: AppBarBackButton( + onTap: () => context.pop(), + ), + ), + body: SafeArea( + child: UploadImageMenu( + onSubmitted: (_) {}, + onUpload: (_) {}, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_picker_screen.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_picker_screen.dart new file mode 100644 index 000000000000..0aa10412bfb8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_picker_screen.dart @@ -0,0 +1,15 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart'; +import 'package:flutter/material.dart'; + +class MobileImagePickerScreen extends StatelessWidget { + static const routeName = '/image_picker'; + + const MobileImagePickerScreen({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return const ImagePickerPage(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart index d77b864d57a0..bbc17f472dcb 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; @@ -15,6 +16,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:http/http.dart'; import 'package:path/path.dart' as p; import 'package:string_validator/string_validator.dart'; @@ -37,65 +39,114 @@ class ImagePlaceholderState extends State { @override Widget build(BuildContext context) { - return AppFlowyPopover( - controller: controller, - direction: PopoverDirection.bottomWithCenterAligned, - constraints: const BoxConstraints( - maxWidth: 540, - maxHeight: 360, - minHeight: 80, + final Widget child = DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(4), ), - clickHandler: PopoverClickHandler.gestureDetector, - popupBuilder: (context) { - return UploadImageMenu( - onSelectedLocalImage: (path) { - controller.close(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - await insertLocalImage(path); - }); - }, - onSelectedAIImage: (url) { - controller.close(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - await insertAIImage(url); - }); - }, - onSelectedNetworkImage: (url) { - controller.close(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - await insertNetworkImage(url); - }); - }, - ); - }, - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant, + child: FlowyHover( + style: HoverStyle( borderRadius: BorderRadius.circular(4), ), - child: FlowyHover( - style: HoverStyle( - borderRadius: BorderRadius.circular(4), - ), - child: SizedBox( - height: 48, - child: Row( - children: [ - const HSpace(10), - const FlowySvg( - FlowySvgs.image_placeholder_s, - size: Size.square(24), - ), - const HSpace(10), - FlowyText( - LocaleKeys.document_plugins_image_addAnImage.tr(), - ), - ], - ), + child: SizedBox( + height: 52, + child: Row( + children: [ + const HSpace(10), + const FlowySvg( + FlowySvgs.image_placeholder_s, + size: Size.square(24), + ), + const HSpace(10), + FlowyText( + LocaleKeys.document_plugins_image_addAnImage.tr(), + ), + ], ), ), ), ); + + if (PlatformExtension.isDesktopOrWeb) { + return AppFlowyPopover( + controller: controller, + direction: PopoverDirection.bottomWithCenterAligned, + constraints: const BoxConstraints( + maxWidth: 540, + maxHeight: 360, + minHeight: 80, + ), + clickHandler: PopoverClickHandler.gestureDetector, + popupBuilder: (context) { + return UploadImageMenu( + onSelectedLocalImage: (path) { + controller.close(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + await insertLocalImage(path); + }); + }, + onSelectedAIImage: (url) { + controller.close(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + await insertAIImage(url); + }); + }, + onSelectedNetworkImage: (url) { + controller.close(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + await insertNetworkImage(url); + }); + }, + ); + }, + child: child, + ); + } else { + return GestureDetector( + onTap: () { + showUploadImageMenu(); + }, + child: child, + ); + } + } + + void showUploadImageMenu() { + if (PlatformExtension.isDesktopOrWeb) { + controller.show(); + } else { + showFlowyMobileBottomSheet( + context, + title: LocaleKeys.editor_image.tr(), + builder: (context) { + return ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 340, + minHeight: 80, + ), + child: UploadImageMenu( + supportTypes: const [ + UploadImageType.local, + UploadImageType.url, + UploadImageType.unsplash, + ], + onSelectedLocalImage: (path) async { + context.pop(); + await insertLocalImage(path); + }, + onSelectedAIImage: (url) async { + context.pop(); + await insertAIImage(url); + }, + onSelectedNetworkImage: (url) async { + context.pop(); + await insertNetworkImage(url); + }, + ), + ); + }, + ); + } } Future insertLocalImage(String? url) async { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/mobile_image_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/mobile_image_toolbar_item.dart new file mode 100644 index 000000000000..7d5c95ea51c1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/mobile_image_toolbar_item.dart @@ -0,0 +1,17 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +final imageMobileToolbarItem = MobileToolbarItem.action( + itemIconBuilder: (_, __) => const FlowySvg(FlowySvgs.m_toolbar_imae_lg), + actionHandler: (editorState, selection) async { + final imagePlaceholderKey = GlobalKey(); + await editorState.insertEmptyImageBlock(imagePlaceholderKey); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + imagePlaceholderKey.currentState?.showUploadImageMenu(); + }); + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart index 2c1ec6f1f826..37a2a0694649 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart @@ -88,7 +88,7 @@ class _OpenAIImageWidgetState extends State { ); }, ), - ) + ), ], ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.dart index 7488fb09055b..d0d589d206f7 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.dart @@ -104,7 +104,7 @@ class _StabilityAIImageWidgetState extends State { ); }, ), - ) + ), ], ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart new file mode 100644 index 000000000000..3403a1ff31b5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart @@ -0,0 +1,43 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; + +class UnSupportImageWidget extends StatelessWidget { + const UnSupportImageWidget({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(4), + ), + child: FlowyHover( + style: HoverStyle( + borderRadius: BorderRadius.circular(4), + ), + child: SizedBox( + height: 52, + child: Row( + children: [ + const HSpace(10), + const FlowySvg( + FlowySvgs.image_placeholder_s, + size: Size.square(24), + ), + const HSpace(10), + FlowyText( + LocaleKeys.document_imageBlock_unableToLoadImage.tr(), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart index 40d1daf0724f..71c95ac4450a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart @@ -1,10 +1,12 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; class UploadImageFileWidget extends StatelessWidget { const UploadImageFileWidget({ @@ -19,31 +21,34 @@ class UploadImageFileWidget extends StatelessWidget { @override Widget build(BuildContext context) { return FlowyHover( - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTapDown: (_) async { - final result = await getIt().pickFiles( - dialogTitle: '', - allowMultiple: false, - type: FileType.image, - allowedExtensions: allowedExtensions, - ); - onPickFile(result?.files.firstOrNull?.path); - }, - child: Container( + child: FlowyButton( + showDefaultBoxDecorationOnMobile: true, + text: Container( + margin: const EdgeInsets.all(4.0), alignment: Alignment.center, - padding: const EdgeInsets.symmetric(vertical: 8.0), - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.surfaceVariant, - width: 1.0, - ), - ), child: FlowyText( LocaleKeys.document_imageBlock_upload_placeholder.tr(), ), ), + onTap: _uploadImage, ), ); } + + Future _uploadImage() async { + if (PlatformExtension.isDesktopOrWeb) { + // on desktop, the users can pick a image file from folder + final result = await getIt().pickFiles( + dialogTitle: '', + allowMultiple: false, + type: FileType.image, + allowedExtensions: allowedExtensions, + ); + onPickFile(result?.files.firstOrNull?.path); + } else { + // on mobile, the users can pick a image file from camera or image library + final result = await ImagePicker().pickImage(source: ImageSource.gallery); + onPickFile(result?.path); + } + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart index 0b8eed1f98d6..961e2deab6a5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart @@ -1,11 +1,15 @@ import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.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_file_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide ColorOption; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; @@ -15,7 +19,8 @@ enum UploadImageType { url, unsplash, stabilityAI, - openAI; + openAI, + color; String get description { switch (this) { @@ -29,6 +34,8 @@ enum UploadImageType { return LocaleKeys.document_imageBlock_ai_label.tr(); case UploadImageType.stabilityAI: return LocaleKeys.document_imageBlock_stability_ai_label.tr(); + case UploadImageType.color: + return LocaleKeys.document_plugins_cover_colors.tr(); } } } @@ -39,19 +46,23 @@ class UploadImageMenu extends StatefulWidget { required this.onSelectedLocalImage, required this.onSelectedAIImage, required this.onSelectedNetworkImage, + this.onSelectedColor, + this.supportTypes = UploadImageType.values, }); final void Function(String? path) onSelectedLocalImage; final void Function(String url) onSelectedAIImage; final void Function(String url) onSelectedNetworkImage; + final void Function(String color)? onSelectedColor; + final List supportTypes; @override State createState() => _UploadImageMenuState(); } class _UploadImageMenuState extends State { + late final List values; int currentTabIndex = 0; - List values = UploadImageType.values; bool supportOpenAI = false; bool supportStabilityAI = false; @@ -59,6 +70,7 @@ class _UploadImageMenuState extends State { void initState() { super.initState(); + values = widget.supportTypes; UserBackendService.getCurrentUserProfile().then( (value) { final supportOpenAI = value.fold( @@ -97,15 +109,16 @@ class _UploadImageMenuState extends State { Theme.of(context).colorScheme.secondary, ), padding: EdgeInsets.zero, - // splashBorderRadius: BorderRadius.circular(4), tabs: values .map( (e) => FlowyHover( style: const HoverStyle(borderRadius: BorderRadius.zero), child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - vertical: 8.0, + padding: EdgeInsets.only( + left: 12.0, + right: 12.0, + bottom: 8.0, + top: PlatformExtension.isMobile ? 0 : 8.0, ), child: FlowyText(e.description), ), @@ -123,18 +136,23 @@ class _UploadImageMenuState extends State { } Widget _buildTab() { - final type = UploadImageType.values[currentTabIndex]; + final constraints = + PlatformExtension.isMobile ? const BoxConstraints(minHeight: 92) : null; + final type = values[currentTabIndex]; switch (type) { case UploadImageType.local: - return Padding( + return Container( padding: const EdgeInsets.all(8.0), + alignment: Alignment.center, + constraints: constraints, child: UploadImageFileWidget( onPickFile: widget.onSelectedLocalImage, ), ); case UploadImageType.url: - return Padding( + return Container( padding: const EdgeInsets.all(8.0), + constraints: constraints, child: EmbedImageUrlWidget( onSubmit: widget.onSelectedNetworkImage, ), @@ -151,8 +169,9 @@ class _UploadImageMenuState extends State { case UploadImageType.openAI: return supportOpenAI ? Expanded( - child: Padding( + child: Container( padding: const EdgeInsets.all(8.0), + constraints: constraints, child: OpenAIImageWidget( onSelectNetworkImage: widget.onSelectedAIImage, ), @@ -167,7 +186,7 @@ class _UploadImageMenuState extends State { case UploadImageType.stabilityAI: return supportStabilityAI ? Expanded( - child: Padding( + child: Container( padding: const EdgeInsets.all(8.0), child: StabilityAIImageWidget( onSelectImage: widget.onSelectedLocalImage, @@ -181,6 +200,28 @@ class _UploadImageMenuState extends State { .tr(), ), ); + case UploadImageType.color: + final theme = Theme.of(context); + return Container( + constraints: constraints, + padding: const EdgeInsets.all(8.0), + alignment: Alignment.center, + child: CoverColorPicker( + pickerBackgroundColor: theme.cardColor, + pickerItemHoverColor: theme.hoverColor, + backgroundColorOptions: FlowyTint.values + .map( + (t) => ColorOption( + colorHex: t.color(context).toHex(), + name: t.tintName(AppFlowyEditorL10n.current), + ), + ) + .toList(), + onSubmittedBackgroundColorHex: (color) { + widget.onSelectedColor?.call(color); + }, + ), + ); } } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_block_component.dart index bb17ff1551de..a3071ccef5fd 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_block_component.dart @@ -1,7 +1,9 @@ import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; import 'package:flutter/material.dart'; @@ -44,7 +46,7 @@ SelectionMenuItem mathEquationItem = SelectionMenuItem.node( final mathEquationState = editorState.getNodeAtPath(path)?.key.currentState; if (mathEquationState != null && - mathEquationState is _MathEquationBlockComponentWidgetState) { + mathEquationState is MathEquationBlockComponentWidgetState) { mathEquationState.showEditingDialog(); } }); @@ -89,10 +91,10 @@ class MathEquationBlockComponentWidget extends BlockComponentStatefulWidget { @override State createState() => - _MathEquationBlockComponentWidgetState(); + MathEquationBlockComponentWidgetState(); } -class _MathEquationBlockComponentWidgetState +class MathEquationBlockComponentWidgetState extends State with BlockComponentConfigurable { @override @@ -112,35 +114,34 @@ class _MathEquationBlockComponentWidgetState return InkWell( onHover: (value) => setState(() => isHover = value), onTap: showEditingDialog, - child: _buildMathEquation(context), + child: _build(context), ); } - Widget _buildMathEquation(BuildContext context) { + Widget _build(BuildContext context) { Widget child = Container( - width: double.infinity, - constraints: const BoxConstraints(minHeight: 50), - padding: padding, + constraints: const BoxConstraints(minHeight: 52), decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8.0)), - color: isHover || formula.isEmpty - ? Theme.of(context).colorScheme.tertiaryContainer - : Colors.transparent, + color: formula.isNotEmpty + ? Colors.transparent + : Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(4), ), - child: Center( + child: FlowyHover( + style: HoverStyle( + borderRadius: BorderRadius.circular(4), + ), child: formula.isEmpty - ? FlowyText.medium( - LocaleKeys.document_plugins_mathEquation_addMathEquation.tr(), - fontSize: 16, - ) - : Math.tex( - formula, - textStyle: const TextStyle(fontSize: 20), - mathStyle: MathStyle.display, - ), + ? _buildPlaceholderWidget(context) + : _buildMathEquation(context), ), ); + child = Padding( + padding: padding, + child: child, + ); + if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, @@ -149,9 +150,43 @@ class _MathEquationBlockComponentWidgetState ); } + if (PlatformExtension.isMobile) { + child = MobileBlockActionButtons( + node: node, + editorState: editorState, + child: child, + ); + } + return child; } + Widget _buildPlaceholderWidget(BuildContext context) { + return SizedBox( + height: 52, + child: Row( + children: [ + const HSpace(10), + const Icon(Icons.text_fields_outlined), + const HSpace(10), + FlowyText( + LocaleKeys.document_plugins_mathEquation_addMathEquation.tr(), + ), + ], + ), + ); + } + + Widget _buildMathEquation(BuildContext context) { + return Center( + child: Math.tex( + formula, + textStyle: const TextStyle(fontSize: 20), + mathStyle: MathStyle.display, + ), + ); + } + void showEditingDialog() { showDialog( context: context, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/mobile_math_equation_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/mobile_math_equation_toolbar_item.dart new file mode 100644 index 000000000000..0c97ccf0ae2d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/mobile_math_equation_toolbar_item.dart @@ -0,0 +1,44 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +final mathEquationMobileToolbarItem = MobileToolbarItem.action( + itemIconBuilder: (_, __) => + const SizedBox(width: 22, child: FlowySvg(FlowySvgs.math_lg)), + actionHandler: (editorState, selection) async { + if (!selection.isCollapsed) { + return; + } + final path = selection.start.path; + final node = editorState.getNodeAtPath(path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + final transaction = editorState.transaction; + final insertedNode = mathEquationNode(); + + if (delta.isEmpty) { + transaction + ..insertNode(path, insertedNode) + ..deleteNode(node); + } else { + transaction.insertNode( + path.next, + insertedNode, + ); + } + + await editorState.apply(transaction); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + final mathEquationState = + editorState.getNodeAtPath(path)?.key.currentState; + if (mathEquationState != null && + mathEquationState is MathEquationBlockComponentWidgetState) { + mathEquationState.showEditingDialog(); + } + }); + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/slash_menu_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/slash_menu_items.dart index c2e8bf0769f0..75b418e3e92b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/slash_menu_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/slash_menu_items.dart @@ -37,7 +37,7 @@ Future _insertDateReference(EditorState editorState) async { MentionBlockKeys.mention: { MentionBlockKeys.type: MentionType.date.name, MentionBlockKeys.date: DateTime.now().toIso8601String(), - } + }, }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_align_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_align_toolbar_item.dart new file mode 100644 index 000000000000..6171c362e331 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_align_toolbar_item.dart @@ -0,0 +1,103 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +final mobileAlignToolbarItem = MobileToolbarItem.withMenu( + itemIconBuilder: (_, editorState) { + return onlyShowInTextType(editorState) + ? const FlowySvg( + FlowySvgs.toolbar_align_center_s, + size: Size.square(32), + ) + : null; + }, + itemMenuBuilder: (editorState, selection, _) { + return _MobileAlignMenu( + editorState: editorState, + selection: selection, + ); + }, +); + +class _MobileAlignMenu extends StatelessWidget { + const _MobileAlignMenu({ + required this.editorState, + required this.selection, + }); + + final Selection selection; + final EditorState editorState; + + @override + Widget build(BuildContext context) { + return GridView.count( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 3, + shrinkWrap: true, + children: [ + _buildAlignmentButton( + context, + 'left', + LocaleKeys.document_plugins_optionAction_left.tr(), + ), + _buildAlignmentButton( + context, + 'center', + LocaleKeys.document_plugins_optionAction_center.tr(), + ), + _buildAlignmentButton( + context, + 'right', + LocaleKeys.document_plugins_optionAction_right.tr(), + ), + ], + ); + } + + Widget _buildAlignmentButton( + BuildContext context, + String alignment, + String label, + ) { + final nodes = editorState.getNodesInSelection(selection); + if (nodes.isEmpty) { + const SizedBox.shrink(); + } + + bool isSatisfyCondition(bool Function(Object? value) test) { + return nodes.every( + (n) => test(n.attributes[blockComponentAlign]), + ); + } + + final data = switch (alignment) { + 'left' => FlowySvgs.toolbar_align_left_s, + 'center' => FlowySvgs.toolbar_align_center_s, + 'right' => FlowySvgs.toolbar_align_right_s, + _ => throw UnimplementedError(), + }; + final isSelected = isSatisfyCondition((value) => value == alignment); + + return MobileToolbarItemMenuBtn( + icon: FlowySvg(data, size: const Size.square(28)), + label: FlowyText(label), + isSelected: isSelected, + onPressed: () async { + await editorState.updateNode( + selection, + (node) => node.copyWith( + attributes: { + ...node.attributes, + blockComponentAlign: alignment, + }, + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_blocks_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_blocks_toolbar_item.dart new file mode 100644 index 000000000000..8cece8ec97d3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_blocks_toolbar_item.dart @@ -0,0 +1,122 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +final mobileBlocksToolbarItem = MobileToolbarItem.withMenu( + itemIconBuilder: (_, __) => + const AFMobileIcon(afMobileIcons: AFMobileIcons.list), + itemMenuBuilder: (editorState, selection, _) { + return _MobileListMenu( + editorState: editorState, + selection: selection, + ); + }, +); + +class _MobileListMenu extends StatelessWidget { + const _MobileListMenu({ + required this.editorState, + required this.selection, + }); + + final Selection selection; + final EditorState editorState; + + @override + Widget build(BuildContext context) { + return GridView.count( + crossAxisCount: 2, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 5, + shrinkWrap: true, + children: [ + // bulleted list, numbered list + _buildListButton( + context, + BulletedListBlockKeys.type, + const AFMobileIcon(afMobileIcons: AFMobileIcons.bulletedList), + LocaleKeys.document_plugins_bulletedList.tr(), + ), + _buildListButton( + context, + NumberedListBlockKeys.type, + const AFMobileIcon(afMobileIcons: AFMobileIcons.numberedList), + LocaleKeys.document_plugins_numberedList.tr(), + ), + + // todo list, quote list + _buildListButton( + context, + TodoListBlockKeys.type, + const AFMobileIcon(afMobileIcons: AFMobileIcons.checkbox), + LocaleKeys.document_plugins_todoList.tr(), + ), + _buildListButton( + context, + QuoteBlockKeys.type, + const AFMobileIcon(afMobileIcons: AFMobileIcons.quote), + LocaleKeys.document_plugins_quoteList.tr(), + ), + + // toggle list, callout + _buildListButton( + context, + ToggleListBlockKeys.type, + const FlowySvg( + FlowySvgs.toggle_list_s, + size: Size.square(24), + ), + LocaleKeys.document_plugins_toggleList.tr(), + ), + _buildListButton( + context, + CalloutBlockKeys.type, + const Icon(Icons.note_rounded), + LocaleKeys.document_plugins_callout.tr(), + ), + ], + ); + } + + Widget _buildListButton( + BuildContext context, + String listBlockType, + Widget icon, + String label, + ) { + final node = editorState.getNodeAtPath(selection.start.path); + final type = node?.type; + if (node == null || type == null) { + const SizedBox.shrink(); + } + final isSelected = type == listBlockType; + return MobileToolbarItemMenuBtn( + icon: icon, + label: FlowyText(label), + isSelected: isSelected, + onPressed: () async { + await editorState.formatNode( + selection, + (node) { + final attributes = { + ParagraphBlockKeys.delta: (node.delta ?? Delta()).toJson(), + if (listBlockType == TodoListBlockKeys.type) + TodoListBlockKeys.checked: false, + if (listBlockType == CalloutBlockKeys.type) + CalloutBlockKeys.icon: '📌', + }; + return node.copyWith( + type: isSelected ? ParagraphBlockKeys.type : listBlockType, + attributes: attributes, + ); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_indent_toolbar_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_indent_toolbar_items.dart new file mode 100644 index 000000000000..fb07a38a25aa --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_indent_toolbar_items.dart @@ -0,0 +1,24 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +final mobileIndentToolbarItem = MobileToolbarItem.action( + itemIconBuilder: (_, editorState) { + return onlyShowInTextType(editorState) + ? const Icon(Icons.format_indent_increase_rounded) + : null; + }, + actionHandler: (editorState, selection) { + indentCommand.execute(editorState); + }, +); + +final mobileOutdentToolbarItem = MobileToolbarItem.action( + itemIconBuilder: (_, editorState) { + return onlyShowInTextType(editorState) + ? const Icon(Icons.format_indent_decrease_rounded) + : null; + }, + actionHandler: (editorState, selection) { + outdentCommand.execute(editorState); + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/auto_completion_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/auto_completion_node_widget.dart index 2541e948152f..e9d87a9d34e2 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/auto_completion_node_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/auto_completion_node_widget.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/build_context_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/text_robot.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart'; @@ -7,6 +8,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/wid import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; @@ -15,8 +17,6 @@ import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:provider/provider.dart'; class AutoCompletionBlockKeys { @@ -157,7 +157,7 @@ class _AutoCompletionBlockComponentState onKeep: _onExit, onRewrite: _onRewrite, onDiscard: _onDiscard, - ) + ), ], ], ), @@ -169,9 +169,12 @@ class _AutoCompletionBlockComponentState return FlowyTextField( hintText: LocaleKeys.document_plugins_autoGeneratorHintText.tr(), controller: controller, - maxLines: 3, + maxLines: 5, focusNode: textFieldFocusNode, autoFocus: false, + hintTextConstraints: const BoxConstraints( + maxHeight: double.infinity, + ), ); } @@ -474,7 +477,7 @@ class AutoCompletionHeader extends StatelessWidget { onTap: () async { await openLearnMorePage(); }, - ) + ), ], ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart index 820c2db84c1f..34c53d4ebcc1 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart @@ -18,7 +18,7 @@ class Loading { children: [ Center( child: CircularProgressIndicator(), - ) + ), ], ); }, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_node_widget.dart index 51b0292d9eaf..7381489ff019 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_node_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_node_widget.dart @@ -254,7 +254,7 @@ class _SmartEditInputWidgetState extends State { onTap: () async { await openLearnMorePage(); }, - ) + ), ], ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart index 37d38be4d855..c019d6a5f9cc 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart @@ -21,9 +21,14 @@ export 'header/custom_cover_picker.dart'; export 'header/document_header_node_widget.dart'; export 'image/image_menu.dart'; export 'image/image_selection_menu.dart'; +export 'image/mobile_image_toolbar_item.dart'; export 'inline_math_equation/inline_math_equation.dart'; export 'inline_math_equation/inline_math_equation_toolbar_item.dart'; export 'math_equation/math_equation_block_component.dart'; +export 'math_equation/mobile_math_equation_toolbar_item.dart'; +export 'mobile_toolbar_item/mobile_align_toolbar_item.dart'; +export 'mobile_toolbar_item/mobile_blocks_toolbar_item.dart'; +export 'mobile_toolbar_item/mobile_indent_toolbar_items.dart'; export 'openai/widgets/auto_completion_node_widget.dart'; export 'openai/widgets/smart_edit_node_widget.dart'; export 'openai/widgets/smart_edit_toolbar_item.dart'; @@ -33,3 +38,5 @@ export 'table/table_menu.dart'; export 'table/table_option_action.dart'; export 'toggle/toggle_block_component.dart'; export 'toggle/toggle_block_shortcut_event.dart'; +export 'undo_redo/redo_mobile_toolbar_item.dart'; +export 'undo_redo/undo_mobile_toolbar_item.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart index a9ff605b038e..003ae01d7b2b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart @@ -166,13 +166,17 @@ class _ToggleListBlockComponentWidgetState Container( constraints: const BoxConstraints(minWidth: 26, minHeight: 22), padding: const EdgeInsets.only(right: 4.0), - child: FlowyIconButton( - width: 18.0, - icon: Icon( - collapsed ? Icons.arrow_right : Icons.arrow_drop_down, - size: 18.0, + child: AnimatedRotation( + turns: collapsed ? 0.0 : 0.25, + duration: const Duration(milliseconds: 200), + child: FlowyIconButton( + width: 18.0, + icon: const Icon( + Icons.arrow_right, + size: 18.0, + ), + onPressed: onCollapsed, ), - onPressed: onCollapsed, ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcut_event.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcut_event.dart index 8e9ad2305b5b..6428e0dccf32 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcut_event.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcut_event.dart @@ -70,9 +70,12 @@ CharacterShortcutEvent insertChildNodeInsideToggleList = CharacterShortcutEvent( // insert a toggle list block below the current toggle list block transaction ..deleteText(node, selection.startIndex, slicedDelta.length) - ..insertNode( + ..insertNodes( selection.start.path.next, - toggleListBlockNode(collapsed: true, delta: slicedDelta), + [ + toggleListBlockNode(collapsed: true, delta: slicedDelta), + paragraphNode(), + ], ) ..afterSelection = Selection.collapsed( Position(path: selection.start.path.next, offset: 0), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/undo_redo/redo_mobile_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/undo_redo/redo_mobile_toolbar_item.dart new file mode 100644 index 000000000000..abd445dbc83c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/undo_redo/redo_mobile_toolbar_item.dart @@ -0,0 +1,9 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +final redoMobileToolbarItem = MobileToolbarItem.action( + itemIconBuilder: (_, __) => const FlowySvg(FlowySvgs.m_redo_m), + actionHandler: (editorState, selection) async { + editorState.undoManager.redo(); + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/undo_redo/undo_mobile_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/undo_redo/undo_mobile_toolbar_item.dart new file mode 100644 index 000000000000..bc9d27aeb775 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/undo_redo/undo_mobile_toolbar_item.dart @@ -0,0 +1,9 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +final undoMobileToolbarItem = MobileToolbarItem.action( + itemIconBuilder: (_, __) => const FlowySvg(FlowySvgs.m_undo_m), + actionHandler: (editorState, selection) async { + editorState.undoManager.undo(); + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart index 503332dfef8f..8a4d16b53dd2 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -7,6 +7,7 @@ import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/util/google_font_family_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:collection/collection.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -29,6 +30,10 @@ class EditorStyleCustomizer { throw UnimplementedError(); } + static EdgeInsets get documentPadding => PlatformExtension.isMobile + ? const EdgeInsets.only(left: 20, right: 20) + : const EdgeInsets.only(left: 40, right: 40 + 44); + EditorStyle desktop() { final theme = Theme.of(context); final fontSize = context.read().state.fontSize; @@ -127,7 +132,7 @@ class EditorStyleCustomizer { fontSize + 8, fontSize + 4, fontSize + 2, - fontSize + fontSize, ]; return TextStyle( fontSize: fontSizes.elementAtOrNull(level - 1) ?? fontSize, @@ -175,7 +180,7 @@ class EditorStyleCustomizer { backgroundColor: theme.cardColor, groupTextColor: theme.colorScheme.onBackground.withOpacity(.8), menuItemTextColor: theme.colorScheme.onBackground, - menuItemSelectedColor: theme.hoverColor, + menuItemSelectedColor: theme.colorScheme.secondary, menuItemSelectedTextColor: theme.colorScheme.onSurface, ); } @@ -260,6 +265,19 @@ class EditorStyleCustomizer { ); } + // customize the link on mobile + final href = attributes[AppFlowyRichTextKeys.href] as String?; + if (PlatformExtension.isMobile && href != null) { + return TextSpan( + style: textSpan.style, + text: text.text, + recognizer: TapGestureRecognizer() + ..onTap = () { + safeLaunchUrl(href); + }, + ); + } + return defaultTextSpanDecoratorForAttribute( context, node, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/export_page_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/export_page_widget.dart index 1da2e9da9927..c8609c114e1b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/export_page_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/export_page_widget.dart @@ -31,7 +31,7 @@ class ExportPageWidget extends StatelessWidget { width: 100, height: 30, onPressed: onTap, - ) + ), ], ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/more/more_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/more/more_button.dart index 202e820bb515..53d79ed67924 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/more/more_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/more/more_button.dart @@ -35,7 +35,7 @@ class DocumentMoreButton extends StatelessWidget { BlocProvider.value( value: context.read(), child: const FontSizeSwitcher(), - ) + ), ], ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/share/share_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/share/share_button.dart index f25096955483..3e36b370e0ba 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/share/share_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/share/share_button.dart @@ -112,7 +112,7 @@ class ShareActionListState extends State { return RoundedTextButton( title: LocaleKeys.shareAction_buttonText.tr(), onPressed: () => controller.show(), - textColor: Theme.of(context).colorScheme.onSurface, + textColor: Theme.of(context).colorScheme.onPrimary, ); }, onSelected: (action, controller) async { diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart index 9f84abec9ad6..75ac0ead2893 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart @@ -124,7 +124,7 @@ class DateReferenceService { MentionBlockKeys.mention: { MentionBlockKeys.type: MentionType.date.name, MentionBlockKeys.date: date.toIso8601String(), - } + }, }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart index 3bfb4b2a0b83..0904105dbae0 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart @@ -4,16 +4,20 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; class InlinePageReferenceService { - InlinePageReferenceService({required this.currentViewId}) { + InlinePageReferenceService({ + required this.currentViewId, + }) { init(); } final Completer _initCompleter = Completer(); + final String currentViewId; late final ViewBackendService service; @@ -79,6 +83,7 @@ class InlinePageReferenceService { final pageSelectionMenuItem = InlineActionsMenuItem( keywords: [view.name.toLowerCase()], label: view.name, + icon: (onSelected) => view.defaultIcon(), onSelected: (context, editorState, menuService, replace) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { @@ -104,7 +109,7 @@ class InlinePageReferenceService { MentionBlockKeys.mention: { MentionBlockKeys.type: MentionType.page.name, MentionBlockKeys.pageId: view.id, - } + }, }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart index efbe880a8cbe..6b5f29af99f6 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart @@ -150,7 +150,7 @@ class ReminderReferenceService { MentionBlockKeys.type: MentionType.reminder.name, MentionBlockKeys.date: date.toIso8601String(), MentionBlockKeys.uid: reminder.id, - } + }, }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart index 1dffa7edd015..3db1fa29a90c 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart @@ -18,6 +18,7 @@ class InlineActionsMenu extends InlineActionsMenuService { required this.service, required this.initialResults, required this.style, + this.startCharAmount = 1, }); final BuildContext context; @@ -28,6 +29,8 @@ class InlineActionsMenu extends InlineActionsMenuService { @override final InlineActionsMenuStyle style; + final int startCharAmount; + OverlayEntry? _menuEntry; bool selectionChangedByMenu = false; @@ -130,6 +133,7 @@ class InlineActionsMenu extends InlineActionsMenuService { onDismiss: dismiss, onSelectionUpdate: _onSelectionUpdate, style: style, + startCharAmount: startCharAmount, ), ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart index 9aa6f7ba0114..4d8a64e6dac4 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart @@ -1,5 +1,4 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; @@ -51,6 +50,7 @@ class InlineActionsHandler extends StatefulWidget { required this.onDismiss, required this.onSelectionUpdate, required this.style, + this.startCharAmount = 1, }); final InlineActionsService service; @@ -60,6 +60,7 @@ class InlineActionsHandler extends StatefulWidget { final VoidCallback onDismiss; final VoidCallback onSelectionUpdate; final InlineActionsMenuStyle style; + final int startCharAmount; @override State createState() => _InlineActionsHandlerState(); @@ -99,10 +100,7 @@ class _InlineActionsHandlerState extends State { _resetSelection(); newResults.sortByStartsWithKeyword(_search); - - setState(() { - results = newResults; - }); + setState(() => results = newResults); } void _resetSelection() { @@ -116,10 +114,9 @@ class _InlineActionsHandlerState extends State { @override void initState() { super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNode.requestFocus(); - }); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _focusNode.requestFocus(), + ); startOffset = widget.editorState.selection?.endIndex ?? 0; } @@ -163,6 +160,8 @@ class _InlineActionsHandlerState extends State { isGroupSelected: _selectedGroup == index, selectedIndex: _selectedIndex, onSelected: widget.onDismiss, + startOffset: startOffset - widget.startCharAmount, + endOffset: _search.length + widget.startCharAmount, ), ) .toList(), @@ -200,7 +199,10 @@ class _InlineActionsHandlerState extends State { context, widget.editorState, widget.menuService, - (startOffset - 1, _search.length + 1), + ( + startOffset - widget.startCharAmount, + _search.length + widget.startCharAmount + ), ); widget.onDismiss(); @@ -212,7 +214,7 @@ class _InlineActionsHandlerState extends State { } else if (event.logicalKey == LogicalKeyboardKey.backspace) { if (_search.isEmpty) { widget.onDismiss(); - widget.editorState.deleteBackward(); // Delete '@' + widget.editorState.deleteBackward(); } else { widget.onSelectionUpdate(); widget.editorState.deleteBackward(); @@ -224,7 +226,7 @@ class _InlineActionsHandlerState extends State { ![ ...moveKeys, LogicalKeyboardKey.arrowLeft, - LogicalKeyboardKey.arrowRight + LogicalKeyboardKey.arrowRight, ].contains(event.logicalKey)) { /// Prevents dismissal of context menu by notifying the parent /// that the selection change occurred from the handler. @@ -282,16 +284,12 @@ class _InlineActionsHandlerState extends State { return; } - /// Grab index of the first character in command (right after @) - final startIndex = - delta.toPlainText().lastIndexOf(inlineActionCharacter) + 1; - search = widget.editorState .getTextInSelection( selection.copyWith( - start: selection.start.copyWith(offset: startIndex), + start: selection.start.copyWith(offset: startOffset), end: selection.start - .copyWith(offset: startIndex + _search.length + 1), + .copyWith(offset: startOffset + _search.length + 1), ), ) .join(); @@ -331,8 +329,9 @@ class _InlineActionsHandlerState extends State { return; } - search = delta - .toPlainText() - .substring(startOffset, startOffset - 1 + _search.length); + search = delta.toPlainText().substring( + startOffset, + startOffset - 1 + _search.length, + ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart index 1f568e8f3e36..498e91bb9e9b 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart @@ -2,6 +2,7 @@ import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; @@ -13,6 +14,8 @@ class InlineActionsGroup extends StatelessWidget { required this.menuService, required this.style, required this.onSelected, + required this.startOffset, + required this.endOffset, this.isGroupSelected = false, this.selectedIndex = 0, }); @@ -22,6 +25,8 @@ class InlineActionsGroup extends StatelessWidget { final InlineActionsMenuService menuService; final InlineActionsMenuStyle style; final VoidCallback onSelected; + final int startOffset; + final int endOffset; final bool isGroupSelected; final int selectedIndex; @@ -43,6 +48,8 @@ class InlineActionsGroup extends StatelessWidget { isSelected: isGroupSelected && index == selectedIndex, style: style, onSelected: onSelected, + startOffset: startOffset, + endOffset: endOffset, ), ), ], @@ -60,6 +67,8 @@ class InlineActionsWidget extends StatefulWidget { required this.isSelected, required this.style, required this.onSelected, + required this.startOffset, + required this.endOffset, }); final InlineActionsMenuItem item; @@ -68,57 +77,26 @@ class InlineActionsWidget extends StatefulWidget { final bool isSelected; final InlineActionsMenuStyle style; final VoidCallback onSelected; + final int startOffset; + final int endOffset; @override State createState() => _InlineActionsWidgetState(); } class _InlineActionsWidgetState extends State { - bool isHovering = false; - @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: SizedBox( width: 200, - child: widget.item.icon != null - ? TextButton.icon( - onPressed: _onPressed, - style: ButtonStyle( - alignment: Alignment.centerLeft, - backgroundColor: widget.isSelected - ? MaterialStateProperty.all( - widget.style.menuItemSelectedColor, - ) - : MaterialStateProperty.all(Colors.transparent), - ), - icon: widget.item.icon!.call(widget.isSelected || isHovering), - label: FlowyText.regular( - widget.item.label, - color: widget.isSelected - ? widget.style.menuItemSelectedTextColor - : widget.style.menuItemTextColor, - ), - ) - : TextButton( - onPressed: _onPressed, - style: ButtonStyle( - alignment: Alignment.centerLeft, - backgroundColor: widget.isSelected - ? MaterialStateProperty.all( - widget.style.menuItemSelectedColor, - ) - : MaterialStateProperty.all(Colors.transparent), - ), - onHover: (value) => setState(() => isHovering = value), - child: FlowyText.regular( - widget.item.label, - color: widget.isSelected - ? widget.style.menuItemSelectedTextColor - : widget.style.menuItemTextColor, - ), - ), + child: FlowyButton( + isSelected: widget.isSelected, + leftIcon: widget.item.icon?.call(widget.isSelected), + text: FlowyText.regular(widget.item.label), + onTap: _onPressed, + ), ), ); } @@ -129,7 +107,7 @@ class _InlineActionsWidgetState extends State { context, widget.editorState, widget.menuService, - (0, 0), + (widget.startOffset, widget.endOffset), ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart b/frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart index 9b0631b9b035..2af389562dcf 100644 --- a/frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart @@ -110,7 +110,7 @@ class _TrashPageState extends State { onTap: () => context.read().add(const TrashEvent.deleteAll()), ), - ) + ), ], ), ); diff --git a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart index 01a1944a6d6b..44aa2e5ba9fd 100644 --- a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -1,11 +1,7 @@ import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/network_monitor.dart'; import 'package:appflowy/env/env.dart'; -import 'package:appflowy/plugins/database_view/application/field/field_action_sheet_bloc.dart'; -import 'package:appflowy/plugins/database_view/application/field/field_controller.dart'; -import 'package:appflowy/plugins/database_view/application/field/field_service.dart'; -import 'package:appflowy/plugins/database_view/application/setting/property_bloc.dart'; -import 'package:appflowy/plugins/database_view/grid/application/grid_header_bloc.dart'; +import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart'; @@ -25,7 +21,6 @@ import 'package:flowy_infra/file_picker/file_picker_impl.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart'; -import 'package:appflowy/workspace/application/settings/notifications/notification_settings_cubit.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/application/user/prelude.dart'; import 'package:appflowy/workspace/application/workspace/prelude.dart'; @@ -48,7 +43,7 @@ class DependencyResolver { _resolveHomeDeps(getIt); _resolveFolderDeps(getIt); _resolveDocDeps(getIt); - _resolveGridDeps(getIt); + // _resolveGridDeps(getIt); _resolveCommonService(getIt, mode); } } @@ -149,6 +144,7 @@ void _resolveHomeDeps(GetIt getIt) { getIt.registerSingleton(FToast()); getIt.registerSingleton(MenuSharedState()); + getIt.registerSingleton(MobileRouterRecord()); getIt.registerFactoryParam( (user, _) => UserListener(userProfile: user), @@ -169,13 +165,7 @@ void _resolveHomeDeps(GetIt getIt) { getIt.registerSingleton(NotificationActionBloc()); - getIt.registerSingleton( - NotificationSettingsCubit(), - ); - - getIt.registerSingleton( - ReminderBloc(notificationSettings: getIt()), - ); + getIt.registerSingleton(ReminderBloc()); } void _resolveFolderDeps(GetIt getIt) { @@ -218,21 +208,3 @@ void _resolveDocDeps(GetIt getIt) { (view, _) => DocumentBloc(view: view), ); } - -void _resolveGridDeps(GetIt getIt) { - getIt.registerFactoryParam( - (viewId, fieldController) => GridHeaderBloc( - viewId: viewId, - fieldController: fieldController, - ), - ); - - getIt.registerFactoryParam( - (data, _) => FieldActionSheetBloc(fieldCellContext: data), - ); - - getIt.registerFactoryParam( - (viewId, cache) => - DatabasePropertyBloc(viewId: viewId, fieldController: cache), - ); -} diff --git a/frontend/appflowy_flutter/lib/startup/startup.dart b/frontend/appflowy_flutter/lib/startup/startup.dart index 37a6fa6bcb54..c6a939890c69 100644 --- a/frontend/appflowy_flutter/lib/startup/startup.dart +++ b/frontend/appflowy_flutter/lib/startup/startup.dart @@ -75,7 +75,7 @@ class FlowyRunner { InitSupabaseTask(), InitAppFlowyCloudTask(), const InitAppWidgetTask(), - const InitPlatformServiceTask() + const InitPlatformServiceTask(), ], ], ); diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart index 265f36c3ec9e..01733a7e835f 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart @@ -131,7 +131,7 @@ class _ApplicationWidgetState extends State { )..readLocaleWhenAppLaunch(context), ), BlocProvider( - create: (_) => getIt(), + create: (_) => NotificationSettingsCubit(), ), BlocProvider( create: (_) => DocumentAppearanceCubit()..fetch(), diff --git a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart index 134fd36fd82e..ea272371294f 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart @@ -3,6 +3,8 @@ import 'package:appflowy/mobile/presentation/database/mobile_calendar_screen.dar import 'package:appflowy/mobile/presentation/database/mobile_grid_screen.dart'; import 'package:appflowy/mobile/presentation/favorite/mobile_favorite_page.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_picker_screen.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; @@ -47,6 +49,10 @@ GoRouter generateRouter(Widget child) { // trash _mobileHomeTrashPageRoute(), + + // emoji picker + _mobileEmojiPickerPageRoute(), + _mobileImagePickerPageRoute(), ], // Desktop and Mobile @@ -200,6 +206,30 @@ GoRoute _mobileHomeTrashPageRoute() { ); } +GoRoute _mobileEmojiPickerPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: MobileEmojiPickerScreen.routeName, + pageBuilder: (context, state) { + return const MaterialPage( + child: MobileEmojiPickerScreen(), + ); + }, + ); +} + +GoRoute _mobileImagePickerPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: MobileImagePickerScreen.routeName, + pageBuilder: (context, state) { + return const MaterialPage( + child: MobileImagePickerScreen(), + ); + }, + ); +} + GoRoute _desktopHomeScreenRoute() { return GoRoute( path: DesktopHomeScreen.routeName, diff --git a/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart b/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart index 11ba1e4e510c..c27ad3455e45 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart @@ -1,8 +1,9 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:appflowy/env/backend_env.dart'; import 'package:appflowy/env/env.dart'; import 'package:appflowy_backend/appflowy_backend.dart'; -import 'package:appflowy_backend/env_serde.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; @@ -23,9 +24,9 @@ class InitRustSDKTask extends LaunchTask { Future initialize(LaunchContext context) async { final dir = directory ?? await appFlowyApplicationDataDirectory(); + // Pass the environment variables to the Rust SDK final env = getAppFlowyEnv(); - context.getIt().setEnv(env); - await context.getIt().init(dir); + await context.getIt().init(dir, jsonEncode(env.toJson())); } @override diff --git a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart index 6f1988e67f19..9e08959e6da6 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart @@ -100,7 +100,7 @@ class AFCloudAuthService implements AuthService { authType: AuthTypePB.AFCloud, map: { AuthServiceMapKeys.signInURL: uri.toString(), - AuthServiceMapKeys.deviceId: deviceId + AuthServiceMapKeys.deviceId: deviceId, }, ); final result = await UserEventOauthSignIn(payload) diff --git a/frontend/appflowy_flutter/lib/user/application/auth/supabase_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/supabase_auth_service.dart index ad6021aef279..0263f7bcaffe 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/supabase_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/supabase_auth_service.dart @@ -97,7 +97,7 @@ class SupabaseAuthService implements AuthService { map: { AuthServiceMapKeys.uuid: userId, AuthServiceMapKeys.email: userEmail, - AuthServiceMapKeys.deviceId: await getDeviceId() + AuthServiceMapKeys.deviceId: await getDeviceId(), }, ); }, @@ -140,7 +140,7 @@ class SupabaseAuthService implements AuthService { map: { AuthServiceMapKeys.uuid: userId, AuthServiceMapKeys.email: userEmail, - AuthServiceMapKeys.deviceId: await getDeviceId() + AuthServiceMapKeys.deviceId: await getDeviceId(), }, ); }, diff --git a/frontend/appflowy_flutter/lib/user/application/auth/supabase_mock_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/supabase_mock_auth_service.dart index 20509f346e31..5457cfc5a4a1 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/supabase_mock_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/supabase_mock_auth_service.dart @@ -61,7 +61,7 @@ class MockAuthService implements AuthService { map: { AuthServiceMapKeys.uuid: uuid, AuthServiceMapKeys.email: email, - AuthServiceMapKeys.deviceId: 'MockDeviceId' + AuthServiceMapKeys.deviceId: 'MockDeviceId', }, ); diff --git a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart index 4a34a6d00454..4110131814d0 100644 --- a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart @@ -4,10 +4,10 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/user/application/reminder/reminder_service.dart'; +import 'package:appflowy/user/application/user_settings_service.dart'; import 'package:appflowy/workspace/application/notifications/notification_action.dart'; import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart'; import 'package:appflowy/workspace/application/notifications/notification_service.dart'; -import 'package:appflowy/workspace/application/settings/notifications/notification_settings_cubit.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:bloc/bloc.dart'; @@ -20,16 +20,11 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'reminder_bloc.freezed.dart'; class ReminderBloc extends Bloc { - final NotificationSettingsCubit _notificationSettings; - late final NotificationActionBloc actionBloc; late final ReminderService reminderService; late final Timer timer; - ReminderBloc({ - required NotificationSettingsCubit notificationSettings, - }) : _notificationSettings = notificationSettings, - super(ReminderState()) { + ReminderBloc() : super(ReminderState()) { actionBloc = getIt(); reminderService = const ReminderService(); timer = _periodicCheck(); @@ -146,7 +141,7 @@ class ReminderBloc extends Bloc { Timer _periodicCheck() { return Timer.periodic( const Duration(minutes: 1), - (_) { + (_) async { final now = DateTime.now(); for (final reminder in state.upcomingReminders) { @@ -159,7 +154,9 @@ class ReminderBloc extends Bloc { ); if (scheduledAt.isBefore(now)) { - if (_notificationSettings.state.isNotificationsEnabled) { + final notificationSettings = + await UserSettingsBackendService().getNotificationSettings(); + if (notificationSettings.notificationsEnabled) { NotificationMessage( identifier: reminder.id, title: LocaleKeys.reminderNotification_title.tr(), diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index ad9ee477a668..b1125dd4af1c 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -90,19 +90,23 @@ class UserBackendService { } Future, FlowyError>> getWorkspaces() { - final request = WorkspaceIdPB.create(); + // final request = WorkspaceIdPB.create(); + // return FolderEventReadAllWorkspaces(request).send().then((result) { + // return result.fold( + // (workspaces) => left(workspaces.items), + // (error) => right(error), + // ); + // }); + return Future.value(left([])); + } - return FolderEventReadAllWorkspaces(request).send().then((result) { - return result.fold( - (workspaces) => left(workspaces.items), - (error) => right(error), - ); - }); + Future> openWorkspace(String workspaceId) { + final payload = UserWorkspaceIdPB.create()..workspaceId = workspaceId; + return UserEventOpenWorkspace(payload).send(); } - Future> openWorkspace(String workspaceId) { - final request = WorkspaceIdPB.create()..value = workspaceId; - return FolderEventOpenWorkspace(request).send().then((result) { + Future> getCurrentWorkspace() { + return FolderEventReadCurrentWorkspace().send().then((result) { return result.fold( (workspace) => left(workspace), (error) => right(error), diff --git a/frontend/appflowy_flutter/lib/user/presentation/historical_user.dart b/frontend/appflowy_flutter/lib/user/presentation/historical_user.dart index 9e74e2c0ae1a..52f2d498615b 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/historical_user.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/historical_user.dart @@ -48,7 +48,7 @@ class HistoricalUserList extends StatelessWidget { }, itemCount: state.historicalUsers.length, ), - ) + ), ], ); } diff --git a/frontend/appflowy_flutter/lib/user/presentation/router.dart b/frontend/appflowy_flutter/lib/user/presentation/router.dart index 92c56ef97cec..e3e27aa9529c 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/router.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/router.dart @@ -42,7 +42,7 @@ class AuthRouter { BuildContext context, UserProfilePB userProfile, ) async { - final result = await FolderEventGetCurrentWorkspace().send(); + final result = await FolderEventGetCurrentWorkspaceSetting().send(); result.fold( (workspaceSetting) { // Replace SignInScreen or SkipLogInScreen as root page. @@ -104,7 +104,7 @@ class SplashRouter { }, ); - FolderEventGetCurrentWorkspace().send().then((result) { + FolderEventGetCurrentWorkspaceSetting().send().then((result) { result.fold( (workspaceSettingPB) => pushHomeScreen(context), (r) => null, diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_up_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_up_screen.dart index 97b66e6d2faf..3995af40c0b9 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_up_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_up_screen.dart @@ -81,7 +81,7 @@ class SignUpForm extends StatelessWidget { if (context.read().state.isSubmitting) ...[ const SizedBox(height: 8), const LinearProgressIndicator(value: null), - ] + ], ], ), ); diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart index 3ea6b371da81..3cdba2c1fe3b 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart @@ -75,7 +75,7 @@ class SplashScreen extends StatelessWidget { if (check.requireSecret) { getIt().pushEncryptionScreen(context, userProfile); } else { - final result = await FolderEventGetCurrentWorkspace().send(); + final result = await FolderEventGetCurrentWorkspaceSetting().send(); result.fold( (workspaceSetting) { // After login, replace Splash screen by corresponding home screen diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart index 7c3219ded919..948914867ad5 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart @@ -129,7 +129,7 @@ class WorkspaceErrorDescription extends StatelessWidget { "Error code: ${state.initialError.code.value.toString()}", fontSize: 12, maxLines: 1, - ) + ), ], ); }, diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/desktop_workspace_start_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/desktop_workspace_start_screen.dart index e041c8e75da6..c50ec257851a 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/desktop_workspace_start_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/desktop_workspace_start_screen.dart @@ -101,6 +101,5 @@ Widget _renderCreateButton(BuildContext context) { // same method as in mobile void _popToWorkspace(BuildContext context, WorkspacePB workspace) { - context.read().add(WorkspaceEvent.openWorkspace(workspace)); context.pop(workspace.id); } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart index f587594a7989..d86b6c0fd03d 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart @@ -6,7 +6,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; // TODO(yijing): needs refactor when multiple workspaces are supported @@ -139,6 +138,5 @@ class _MobileWorkspaceStartScreenState // same method as in desktop void _popToWorkspace(BuildContext context, WorkspacePB workspace) { - context.read().add(WorkspaceEvent.openWorkspace(workspace)); context.pop(workspace.id); } diff --git a/frontend/appflowy_flutter/lib/user/presentation/widgets/folder_widget.dart b/frontend/appflowy_flutter/lib/user/presentation/widgets/folder_widget.dart index 0945c2f69cf9..4896e63c5afa 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/widgets/folder_widget.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/widgets/folder_widget.dart @@ -185,7 +185,7 @@ class CreateFolderWidgetState extends State { } }, ), - ) + ), ], ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart index 2d095e211db0..09f8c7e71e65 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart @@ -41,8 +41,8 @@ class HomeBloc extends Bloc { emit(state.copyWith(isLoading: e.isLoading)); }, didReceiveWorkspaceSetting: (_DidReceiveWorkspaceSetting value) { - final latestView = workspaceSetting.hasLatestView() - ? workspaceSetting.latestView + final latestView = value.setting.hasLatestView() + ? value.setting.latestView : state.latestView; emit( diff --git a/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart index 3ffb9a218b43..c877ddab18f5 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart @@ -5,7 +5,6 @@ import 'package:appflowy/workspace/application/workspace/workspace_service.dart' import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:dartz/dartz.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -17,17 +16,17 @@ class MenuBloc extends Bloc { final WorkspaceService _workspaceService; final WorkspaceListener _listener; final UserProfilePB user; - final WorkspacePB workspace; + final String workspaceId; MenuBloc({ required this.user, - required this.workspace, - }) : _workspaceService = WorkspaceService(workspaceId: workspace.id), + required this.workspaceId, + }) : _workspaceService = WorkspaceService(workspaceId: workspaceId), _listener = WorkspaceListener( user: user, - workspaceId: workspace.id, + workspaceId: workspaceId, ), - super(MenuState.initial(workspace)) { + super(MenuState.initial()) { on((event, emit) async { await event.map( initial: (e) async { @@ -122,8 +121,8 @@ class MenuState with _$MenuState { ViewPB? lastCreatedView, }) = _MenuState; - factory MenuState.initial(WorkspacePB workspace) => MenuState( - views: workspace.views, + factory MenuState.initial() => MenuState( + views: [], successOrFailure: left(unit), lastCreatedView: null, ); diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart index 8e4490b9ce90..6958eadd996b 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart @@ -2,9 +2,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database_view/board/presentation/board_page.dart'; import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_page.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart'; -import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_view.dart'; +import 'package:appflowy/plugins/database_view/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/document/document.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:flutter/material.dart'; @@ -104,6 +105,28 @@ extension ViewExtension on ViewPB { } FlowySvgData get iconData => layout.icon; + + Future> getAncestors({ + bool includeSelf = false, + bool includeRoot = false, + }) async { + final ancestors = []; + if (includeSelf) { + final self = await ViewBackendService.getView(id); + ancestors.add(self.getLeftOrNull() ?? this); + } + var parent = await ViewBackendService.getView(parentViewId); + while (parent.isLeft()) { + // parent is not null + final view = parent.getLeftOrNull(); + if (view == null || (!includeRoot && view.parentViewId.isEmpty)) { + break; + } + ancestors.add(view); + parent = await ViewBackendService.getView(view.parentViewId); + } + return ancestors.reversed.toList(); + } } extension ViewLayoutExtension on ViewLayoutPB { diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart index a88e19fa0476..8536d50fdc1e 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart @@ -1,10 +1,9 @@ import 'dart:async'; -import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart'; -import 'package:dartz/dartz.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart'; +import 'package:dartz/dartz.dart'; class ViewBackendService { static Future> createView({ @@ -149,9 +148,24 @@ class ViewBackendService { if (isFavorite != null) { payload.isFavorite = isFavorite; } + return FolderEventUpdateView(payload).send(); } + static Future> updateViewIcon({ + required String viewId, + required String viewIcon, + }) { + final icon = ViewIconPB() + ..ty = ViewIconTypePB.Emoji + ..value = viewIcon; + final payload = UpdateViewIconPayloadPB.create() + ..viewId = viewId + ..icon = icon; + + return FolderEventUpdateViewIcon(payload).send(); + } + // deprecated static Future> moveView({ required String viewId, @@ -200,10 +214,10 @@ class ViewBackendService { Future> fetchViews() async { final result = []; - return FolderEventGetCurrentWorkspace().send().then((value) async { - final workspaces = value.getLeftOrNull(); - if (workspaces != null) { - final views = workspaces.workspace.views; + return FolderEventReadCurrentWorkspace().send().then((value) async { + final workspace = value.getLeftOrNull(); + if (workspace != null) { + final views = workspace.views; for (final view in views) { result.add(view); final childViews = await getAllViews(view); diff --git a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart index 3562a906be41..ef44c02592ff 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart @@ -19,9 +19,6 @@ class WorkspaceBloc extends Bloc { initial: (e) async { await _fetchWorkspaces(emit); }, - openWorkspace: (e) async { - await _openWorkspace(e.workspace, emit); - }, createWorkspace: (e) async { await _createWorkspace(e.name, e.desc, emit); }, @@ -57,22 +54,6 @@ class WorkspaceBloc extends Bloc { ); } - Future _openWorkspace( - WorkspacePB workspace, - Emitter emit, - ) async { - final result = await userService.openWorkspace(workspace.id); - emit( - result.fold( - (workspaces) => state.copyWith(successOrFailure: left(unit)), - (error) { - Log.error(error); - return state.copyWith(successOrFailure: right(error)); - }, - ), - ); - } - Future _createWorkspace( String name, String desc, @@ -98,8 +79,6 @@ class WorkspaceEvent with _$WorkspaceEvent { const factory WorkspaceEvent.initial() = Initial; const factory WorkspaceEvent.createWorkspace(String name, String desc) = CreateWorkspace; - const factory WorkspaceEvent.openWorkspace(WorkspacePB workspace) = - OpenWorkspace; const factory WorkspaceEvent.workspacesReveived( Either, FlowyError> workspacesOrFail, ) = WorkspacesReceived; diff --git a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart index 4883657aff8a..3843469c8777 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart @@ -1,15 +1,12 @@ import 'dart:async'; import 'package:dartz/dartz.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart' show CreateViewPayloadPB, MoveViewPayloadPB, ViewLayoutPB, ViewPB; import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; - class WorkspaceService { final String workspaceId; WorkspaceService({ @@ -38,24 +35,7 @@ class WorkspaceService { } Future> getWorkspace() { - final payload = WorkspaceIdPB.create()..value = workspaceId; - return FolderEventReadAllWorkspaces(payload).send().then((result) { - return result.fold( - (workspaces) { - assert(workspaces.items.length == 1); - - if (workspaces.items.isEmpty) { - return right( - FlowyError.create() - ..msg = LocaleKeys.workspace_notFoundError.tr(), - ); - } else { - return left(workspaces.items[0]); - } - }, - (error) => right(error), - ); - }); + return FolderEventReadCurrentWorkspace().send(); } Future, FlowyError>> getViews() { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart index 5c160f8db1cb..810da9558d46 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart @@ -39,7 +39,7 @@ class DesktopHomeScreen extends StatelessWidget { Widget build(BuildContext context) { return FutureBuilder( future: Future.wait([ - FolderEventGetCurrentWorkspace().send(), + FolderEventGetCurrentWorkspaceSetting().send(), getIt().getUser(), ]), builder: (context, snapshots) { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart index 3c22f99fef14..a0b75e6a248e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart @@ -408,9 +408,10 @@ class _HomeBodyState extends State { final builder = widget.notifier.plugin.widgetBuilder; final pluginWidget = builder.buildWidget( context: PluginContext(onDeleted: onDeleted), - shrinkWrap: true, + shrinkWrap: false, ); + // TODO(Xazin): Board should fill up full width return Padding( padding: builder.contentPadding, child: pluginWidget, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart index f0e837c496d6..681db9e55338 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart @@ -69,7 +69,7 @@ class FavoriteFolder extends StatelessWidget { .read() .add(OpenTabInActivePane(plugin: view.plugin())), ), - ) + ), ], ); }, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart index ae911969395a..bfacd102401d 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart @@ -142,7 +142,7 @@ class _PersonalFolderHeaderState extends State { ); }, ), - ] + ], ], ), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart index b1940846a355..68b638c36136 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart @@ -46,12 +46,12 @@ class HomeSideBar extends StatelessWidget { BlocProvider( create: (_) => MenuBloc( user: user, - workspace: workspaceSetting.workspace, + workspaceId: workspaceSetting.workspaceId, )..add(const MenuEvent.initial()), ), BlocProvider( create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), - ) + ), ], child: MultiBlocListener( listeners: [ @@ -89,6 +89,7 @@ class HomeSideBar extends StatelessWidget { List views, List favoriteViews, ) { + const menuHorizontalInset = EdgeInsets.symmetric(horizontal: 12); return DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceVariant, @@ -96,19 +97,25 @@ class HomeSideBar extends StatelessWidget { right: BorderSide(color: Theme.of(context).dividerColor), ), ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - // top menu - const SidebarTopMenu(), - // user, setting - SidebarUser(user: user, views: views), - const VSpace(20), - // scrollable document list - Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + // top menu + const Padding( + padding: menuHorizontalInset, + child: SidebarTopMenu(), + ), + // user, setting + Padding( + padding: menuHorizontalInset, + child: SidebarUser(user: user, views: views), + ), + const VSpace(20), + // scrollable document list + Expanded( + child: Padding( + padding: menuHorizontalInset, child: SingleChildScrollView( child: SidebarFolder( views: views, @@ -116,17 +123,17 @@ class HomeSideBar extends StatelessWidget { ), ), ), - const VSpace(10), - // trash - const SidebarTrashButton(), - const VSpace(10), - // new page button - const Padding( - padding: EdgeInsets.only(left: 6.0), - child: SidebarNewPageButton(), - ), - ], - ), + ), + const VSpace(10), + // trash + const Padding( + padding: menuHorizontalInset, + child: SidebarTrashButton(), + ), + const VSpace(10), + // new page button + const SidebarNewPageButton(), + ], ), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_folder.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_folder.dart index 74590d790dcf..bdc926ba5223 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_folder.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_folder.dart @@ -18,6 +18,12 @@ class SidebarFolder extends StatelessWidget { @override Widget build(BuildContext context) { + // check if there is any duplicate views + final views = this.views.toSet().toList(); + final favoriteViews = this.favoriteViews.toSet().toList(); + assert(views.length == this.views.length); + assert(favoriteViews.length == favoriteViews.length); + return ValueListenableBuilder( valueListenable: getIt().notifier, builder: (context, value, child) { @@ -27,6 +33,7 @@ class SidebarFolder extends StatelessWidget { // favorite if (favoriteViews.isNotEmpty) ...[ FavoriteFolder( + // remove the duplicate views views: favoriteViews, ), const VSpace(10), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart index 18d443745d98..f5411a3fecf2 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart @@ -48,7 +48,10 @@ class SidebarNewPageButton extends StatelessWidget { height: 60, child: TopBorder( color: Theme.of(context).dividerColor, - child: child, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: child, + ), ), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart index 2131c44692fa..7fc28247f27b 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart @@ -1,10 +1,12 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/base/icon/icon_picker.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/panes/panes_bloc/panes_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/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/rename_view_dialog.dart'; @@ -14,6 +16,7 @@ import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.d import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; @@ -30,12 +33,13 @@ class ViewItem extends StatelessWidget { required this.categoryType, required this.level, required this.onSelected, - required this.isFeedback, this.parentView, this.leftPadding = 10, this.onTertiarySelected, this.isFirstChild = false, this.isDraggable = true, + required this.isFeedback, + this.height = 28.0, }); final ViewPB view; @@ -48,10 +52,6 @@ class ViewItem extends StatelessWidget { // Selected by normal conventions final ViewItemOnSelected onSelected; - /// Identify if the view item is rendered as feedback - /// widget inside DraggableItem - final bool isFeedback; - final ViewPB? parentView; /// The left padding of the view item for each level @@ -68,6 +68,11 @@ class ViewItem extends StatelessWidget { /// It should be false when it's rendered as feedback widget inside DraggableItem final bool isDraggable; + // identify if the view item is rendered as feedback widget inside DraggableItem + final bool isFeedback; + + final double height; + @override Widget build(BuildContext context) { return BlocProvider( @@ -99,6 +104,7 @@ class ViewItem extends StatelessWidget { isFirstChild: isFirstChild, isDraggable: isDraggable, isFeedback: isFeedback, + height: height, ); }, ), @@ -124,6 +130,7 @@ class InnerViewItem extends StatelessWidget { this.onTertiarySelected, this.isFirstChild = false, required this.isFeedback, + required this.height, }); final ViewPB view; @@ -143,6 +150,7 @@ class InnerViewItem extends StatelessWidget { final bool showActions; final ViewItemOnSelected onSelected; final ViewItemOnSelected? onTertiarySelected; + final double height; @override Widget build(BuildContext context) { @@ -158,33 +166,56 @@ class InnerViewItem extends StatelessWidget { isDraggable: isDraggable, leftPadding: leftPadding, isFeedback: isFeedback, + height: height, ); - // If the view is expanded and has child views, render its child views - if (isExpanded && childViews.isNotEmpty) { - final children = childViews.map((childView) { - return ViewItem( - key: ValueKey('${categoryType.name} ${childView.id}'), - parentView: view, - categoryType: categoryType, - isFirstChild: childView.id == childViews.first.id, - view: childView, - level: level + 1, - onSelected: onSelected, - onTertiarySelected: onTertiarySelected, - isDraggable: isDraggable, - leftPadding: leftPadding, - isFeedback: isFeedback, + // if the view is expanded and has child views, render its child views + if (isExpanded) { + if (childViews.isNotEmpty) { + final children = childViews.map((childView) { + return ViewItem( + key: ValueKey('${categoryType.name} ${childView.id}'), + parentView: view, + categoryType: categoryType, + isFirstChild: childView.id == childViews.first.id, + view: childView, + level: level + 1, + onSelected: onSelected, + onTertiarySelected: onTertiarySelected, + isDraggable: isDraggable, + leftPadding: leftPadding, + isFeedback: isFeedback, + ); + }).toList(); + + child = Column( + mainAxisSize: MainAxisSize.min, + children: [ + child, + ...children, + ], ); - }).toList(); - - child = Column( - mainAxisSize: MainAxisSize.min, - children: [ - child, - ...children, - ], - ); + } else { + child = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + child, + Container( + height: height, + alignment: Alignment.centerLeft, + child: Padding( + // add 2px to make the text align with the view item + padding: EdgeInsets.only(left: (level + 1) * leftPadding + 2), + child: FlowyText.medium( + LocaleKeys.noPagesInside.tr(), + color: Theme.of(context).hintColor, + ), + ), + ), + ], + ); + } } // Wrap the child with DraggableItem if isDraggable is true @@ -223,14 +254,15 @@ class SingleInnerViewItem extends StatefulWidget { required this.view, required this.parentView, required this.isExpanded, - required this.isFeedback, required this.level, required this.leftPadding, + this.isDraggable = true, required this.categoryType, required this.showActions, required this.onSelected, this.onTertiarySelected, - this.isDraggable = true, + required this.isFeedback, + required this.height, }); final ViewPB view; @@ -243,17 +275,21 @@ class SingleInnerViewItem extends StatefulWidget { final int level; final double leftPadding; - final FolderCategoryType categoryType; final bool showActions; final ViewItemOnSelected onSelected; final ViewItemOnSelected? onTertiarySelected; final bool isDraggable; + final FolderCategoryType categoryType; + final double height; @override State createState() => _SingleInnerViewItemState(); } class _SingleInnerViewItemState extends State { + final controller = PopoverController(); + bool isIconPickerOpened = false; + @override Widget build(BuildContext context) { if (widget.isFeedback) { @@ -265,7 +301,8 @@ class _SingleInnerViewItemState extends State { hoverColor: Theme.of(context).colorScheme.secondary, ), resetHoverOnRebuild: widget.showActions, - buildWhenOnHover: () => !widget.showActions && !_isDragging, + buildWhenOnHover: () => + !widget.showActions && !_isDragging && !isIconPickerOpened, builder: (_, onHover) => _buildViewItem(onHover), isSelected: () => widget.showActions || @@ -277,11 +314,8 @@ class _SingleInnerViewItemState extends State { final children = [ // Expand icon _buildLeftIcon(), - // Icon - SizedBox.square( - dimension: 16, - child: widget.view.defaultIcon(), - ), + // icon + _buildViewIconButton(), const HSpace(5), // Title Expanded( @@ -289,7 +323,7 @@ class _SingleInnerViewItemState extends State { widget.view.name, overflow: TextOverflow.ellipsis, ), - ) + ), ]; // Hover action @@ -308,7 +342,7 @@ class _SingleInnerViewItemState extends State { onTap: () => widget.onSelected(widget.view), onTertiaryTapDown: (_) => widget.onTertiarySelected?.call(widget.view), child: SizedBox( - height: 26, + height: widget.height, child: Padding( padding: EdgeInsets.only(left: widget.level * widget.leftPadding), child: Row(children: children), @@ -317,6 +351,47 @@ class _SingleInnerViewItemState extends State { ); } + Widget _buildViewIconButton() { + final icon = widget.view.icon.value.isNotEmpty + ? FlowyText( + widget.view.icon.value, + fontSize: 18.0, + ) + : SizedBox.square( + dimension: 20.0, + child: widget.view.defaultIcon(), + ); + return AppFlowyPopover( + offset: const Offset(20, 0), + controller: controller, + direction: PopoverDirection.rightWithCenterAligned, + constraints: BoxConstraints.loose(const Size(360, 380)), + onClose: () => setState(() { + isIconPickerOpened = false; + }), + child: GestureDetector( + // prevent the tap event from being passed to the parent widget + onTap: () {}, + child: FlowyTooltip( + message: LocaleKeys.document_plugins_cover_changeIcon.tr(), + child: icon, + ), + ), + popupBuilder: (context) { + isIconPickerOpened = true; + return FlowyIconPicker( + onSelected: (result) { + ViewBackendService.updateViewIcon( + viewId: widget.view.id, + viewIcon: result.emoji, + ); + controller.close(); + }, + ); + }, + ); + } + // > button or · button // show > if the view is expandable. // show · if the view can't contain child views. diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart index 854cb8e6c92f..a4bcf376317c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart @@ -22,6 +22,7 @@ class FlowyMessageToast extends StatelessWidget { child: FlowyText.medium( message, fontSize: FontSizes.s16, + maxLines: 3, ), ), ); @@ -32,12 +33,17 @@ void initToastWithContext(BuildContext context) { getIt().init(context); } -void showMessageToast(String message) { +void showMessageToast( + String message, { + BuildContext? context, + ToastGravity gravity = ToastGravity.BOTTOM, +}) { final child = FlowyMessageToast(message: message); - - getIt().showToast( + final toast = context == null ? getIt() : FToast() + ..init(context!); + toast.showToast( child: child, - gravity: ToastGravity.BOTTOM, + gravity: gravity, toastDuration: const Duration(seconds: 3), ); } @@ -49,6 +55,7 @@ void showSnackBarMessage( }) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( + backgroundColor: Theme.of(context).colorScheme.onSecondary, action: !showCancel ? null : SnackBarAction( @@ -60,7 +67,6 @@ void showSnackBarMessage( ), content: FlowyText( message, - color: Theme.of(context).colorScheme.onSurface, ), ), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart index 3562b614d379..87ac72ed9f1b 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart @@ -244,6 +244,7 @@ class NotificationItemActions extends StatelessWidget { tooltipText: LocaleKeys.reminderNotification_tooltipMarkUnread.tr(), icon: const FlowySvg(FlowySvgs.restore_s), + iconColorOnHover: Theme.of(context).colorScheme.onSurface, onPressed: () => onReadChanged?.call(false), ), ] else ...[ @@ -251,6 +252,7 @@ class NotificationItemActions extends StatelessWidget { height: 28, tooltipText: LocaleKeys.reminderNotification_tooltipMarkRead.tr(), + iconColorOnHover: Theme.of(context).colorScheme.onSurface, icon: const FlowySvg(FlowySvgs.messages_s), onPressed: () => onReadChanged?.call(true), ), @@ -266,6 +268,7 @@ class NotificationItemActions extends StatelessWidget { height: 28, tooltipText: LocaleKeys.reminderNotification_tooltipDelete.tr(), icon: const FlowySvg(FlowySvgs.delete_s), + iconColorOnHover: Theme.of(context).colorScheme.onSurface, onPressed: onDelete, ), ], diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart index 82f096347f90..7fec6a28b3bf 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -76,7 +76,7 @@ class SettingsDialog extends StatelessWidget { context.read().state.page, context.read().state.userProfile, ), - ) + ), ], ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart index 65ad4c642e27..6b49630600e7 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart @@ -1,11 +1,10 @@ +import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/style_widget/decoration.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'emoji_picker.dart'; - SelectionMenuItem emojiMenuItem = SelectionMenuItem( name: 'Emoji', icon: (editorState, onSelected, style) => SelectableIconWidget( @@ -45,8 +44,8 @@ void showEmojiPickerMenu( builder: (context) => Material( type: MaterialType.transparency, child: Container( - width: 300, - height: 250, + width: 360, + height: 380, padding: const EdgeInsets.all(4.0), decoration: FlowyDecoration.decoration( Theme.of(context).cardColor, @@ -54,7 +53,7 @@ void showEmojiPickerMenu( ), child: EmojiSelectionMenu( onSubmitted: (emoji) { - editorState.insertTextAtCurrentSelection(emoji.emoji); + editorState.insertTextAtCurrentSelection(emoji); }, onExit: () { // close emoji panel @@ -73,7 +72,7 @@ class EmojiSelectionMenu extends StatefulWidget { required this.onExit, }) : super(key: key); - final void Function(Emoji emoji) onSubmitted; + final void Function(String emoji) onSubmitted; final void Function() onExit; @override @@ -111,9 +110,10 @@ class _EmojiSelectionMenuState extends State { @override Widget build(BuildContext context) { - return EmojiPicker( - onEmojiSelected: (category, emoji) => widget.onSubmitted(emoji), - config: buildFlowyEmojiPickerConfig(context), + return FlowyEmojiPicker( + onEmojiSelected: (_, emoji) { + widget.onSubmitted(emoji); + }, ); } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_lists.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_lists.dart index 6cb3681206cd..6cf6f234864f 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_lists.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_lists.dart @@ -373,7 +373,7 @@ final Map smileys = Map.fromIterables([ 'Rescue Worker’s Helmet', 'Lipstick', 'Ring', - 'Briefcase' + 'Briefcase', ], [ '😀', '😃', @@ -734,7 +734,7 @@ final Map smileys = Map.fromIterables([ '⛑', '💄', '💍', - '💼' + '💼', ]); /// Map of all possible emojis along with their names in [Category.ANIMALS] @@ -919,7 +919,7 @@ final Map animals = Map.fromIterables([ 'Christmas Tree', 'Sparkles', 'Tanabata Tree', - 'Pine Decoration' + 'Pine Decoration', ], [ '🐶', '🐱', @@ -1101,7 +1101,7 @@ final Map animals = Map.fromIterables([ '🎄', '✨', '🎋', - '🎍' + '🎍', ]); /// Map of all possible emojis along with their names in [Category.FOODS] @@ -1210,7 +1210,7 @@ final Map foods = Map.fromIterables([ 'Chopsticks', 'Fork and Knife With Plate', 'Fork and Knife', - 'Spoon' + 'Spoon', ], [ '🍇', '🍈', @@ -1316,7 +1316,7 @@ final Map foods = Map.fromIterables([ '🥢', '🍽', '🍴', - '🥄' + '🥄', ]); /// Map of all possible emojis along with their names in [Category.TRAVEL] @@ -1445,7 +1445,7 @@ final Map travel = Map.fromIterables([ 'Passport Control', 'Customs', 'Baggage Claim', - 'Left Luggage' + 'Left Luggage', ], [ '🚣', '🗾', @@ -1571,7 +1571,7 @@ final Map travel = Map.fromIterables([ '🛂', '🛃', '🛄', - '🛅' + '🛅', ]); /// Map of all possible emojis along with their names in [Category.ACTIVITIES] @@ -1667,7 +1667,7 @@ final Map activities = Map.fromIterables([ 'Violin', 'Drum', 'Clapper Board', - 'Bow and Arrow' + 'Bow and Arrow', ], [ '🕴', '🧗', @@ -1760,7 +1760,7 @@ final Map activities = Map.fromIterables([ '🎻', '🥁', '🎬', - '🏹' + '🏹', ]); /// Map of all possible emojis along with their names in [Category.OBJECTS] @@ -1962,7 +1962,7 @@ final Map objects = Map.fromIterables([ 'Coffin', 'Funeral Urn', 'Moai', - 'Potable Water' + 'Potable Water', ], [ '💌', '🕳', @@ -2161,7 +2161,7 @@ final Map objects = Map.fromIterables([ '⚰', '⚱', '🗿', - '🚰' + '🚰', ]); /// Map of all possible emojis along with their names in [Category.SYMBOLS] @@ -2424,7 +2424,7 @@ final Map symbols = Map.fromIterables([ 'Red Triangle Pointed Down', 'Diamond With a Dot', 'White Square Button', - 'Black Square Button' + 'Black Square Button', ], [ '💘', '💝', @@ -2684,7 +2684,7 @@ final Map symbols = Map.fromIterables([ '🔻', '💠', '🔳', - '🔲' + '🔲', ]); /// Map of all possible emojis along with their names in [Category.FLAGS] @@ -2953,7 +2953,7 @@ final Map flags = Map.fromIterables([ 'Flag: Mayotte', 'Flag: South Africa', 'Flag: Zambia', - 'Flag: Zimbabwe' + 'Flag: Zimbabwe', ], [ '🏁', '🚩', @@ -3219,5 +3219,5 @@ final Map flags = Map.fromIterables([ '🇾🇹', '🇿🇦', '🇿🇲', - '🇿🇼' + '🇿🇼', ]); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_picker.dart index a1e4d6007190..0dd72ad54733 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_picker.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_picker.dart @@ -226,7 +226,7 @@ class EmojiPickerState extends State { EmojiCategoryGroup( EmojiCategory.FLAGS, await _getAvailableEmojis(emoji_list.flags, title: 'flags'), - ) + ), ]); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart index b00cac5e618a..e823fa755054 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart @@ -49,7 +49,7 @@ class SettingThirdPartyLogin extends StatelessWidget { fontSize: 16, ), const HSpace(6), - indicator + indicator, ], ), const VSpace(6), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/color_scheme.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/color_scheme.dart index 88b3792f5a65..20d0a12727c9 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/color_scheme.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/color_scheme.dart @@ -122,7 +122,7 @@ class ColorSchemeUploadPopover extends StatelessWidget { false, ), ) - .toList() + .toList(), ], ], ), @@ -168,7 +168,7 @@ class ColorSchemeUploadPopover extends StatelessWidget { width: 20, onPressed: () => bloc.add(DynamicPluginEvent.removePlugin(name: theme)), - ) + ), ], ), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart index 134af35e2e1b..561b6dc5553e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart @@ -34,7 +34,7 @@ class LayoutDirectionSetting extends StatelessWidget { _layoutDirectionItemButton(context, LayoutDirection.rtlLayout), ], ), - ) + ), ], ); } @@ -98,7 +98,7 @@ class TextDirectionSetting extends StatelessWidget { _textDirectionItemButton(context, AppFlowyTextDirection.auto), ], ), - ) + ), ], ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart index fa098069716c..dadafe51d4f7 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart @@ -45,7 +45,7 @@ class _ThemeFontFamilySettingState extends State { trailing: [ FontFamilyDropDown( currentFontFamily: widget.currentFontFamily, - ) + ), ], ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart index 7938c75bc8d9..84da378a2ec5 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart @@ -127,12 +127,12 @@ class ShortcutsListTile extends StatelessWidget { onPressed: () { showKeyListenerDialog(context); }, - ) + ), ], ), Divider( color: Theme.of(context).dividerColor, - ) + ), ], ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart index 976f813a5115..5200b7062362 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart @@ -2,18 +2,19 @@ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/startup/entry_point.dart'; -import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:appflowy/workspace/application/settings/settings_location_cubit.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:url_launcher/url_launcher.dart'; + import '../../../../generated/locale_keys.g.dart'; import '../../../../startup/launch_configuration.dart'; import '../../../../startup/startup.dart'; @@ -172,7 +173,10 @@ class _ChangeStoragePathButtonState extends State<_ChangeStoragePathButton> { onPressed: () async { // pick the new directory and reload app final path = await getIt().getDirectoryPath(); - if (path == null || !mounted || widget.usingPath == path) { + if (path == null || widget.usingPath == path) { + return; + } + if (!mounted) { return; } await context.read().setCustomPath(path); @@ -245,7 +249,10 @@ class _RecoverDefaultStorageButtonState // reset to the default directory and reload app final directory = await appFlowyApplicationDataDirectory(); final path = directory.path; - if (!mounted || widget.usingPath == path) { + if (widget.usingPath == path) { + return; + } + if (!mounted) { return; } await context diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_exporter_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_exporter_widget.dart index 11112c9b8a64..6dd5eb259607 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_exporter_widget.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_exporter_widget.dart @@ -2,21 +2,22 @@ import 'dart:io'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/export/document_exporter.dart'; -import 'package:appflowy/workspace/presentation/home/toast.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:appflowy/workspace/application/settings/settings_file_exporter_cubit.dart'; import 'package:appflowy/workspace/application/settings/share/export_service.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart'; import 'package:dartz/dartz.dart' as dartz; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart' hide WidgetBuilder; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:path/path.dart' as p; + import '../../../../generated/locale_keys.g.dart'; class FileExporterWidget extends StatefulWidget { @@ -33,14 +34,14 @@ class _FileExporterWidgetState extends State { @override Widget build(BuildContext context) { - return FutureBuilder>( - future: FolderEventGetCurrentWorkspace().send(), + return FutureBuilder>( + future: FolderEventReadCurrentWorkspace().send(), builder: (context, snapshot) { if (snapshot.hasData && snapshot.connectionState == ConnectionState.done) { - final workspaces = snapshot.data?.getLeftOrNull(); - if (workspaces != null) { - final views = workspaces.workspace.views; + final workspace = snapshot.data?.getLeftOrNull(); + if (workspace != null) { + final views = workspace.views; cubit ??= SettingsFileExporterCubit(views: views); return BlocProvider.value( value: cubit!, @@ -69,13 +70,13 @@ class _FileExporterWidgetState extends State { .selectOrDeselectAllItems(); }, ), - ) + ), ], ), const VSpace(8), const Expanded(child: _ExpandedList()), const VSpace(8), - _buildButtons() + _buildButtons(), ], ), ); @@ -107,18 +108,20 @@ class _FileExporterWidgetState extends State { final views = cubit!.state.selectedViews; final result = await _AppFlowyFileExporter.exportToPath(exportPath, views); - if (result.$1 && mounted) { - // success - showSnackBarMessage( - context, - LocaleKeys.settings_files_exportFileSuccess.tr(), - ); - } else { - showSnackBarMessage( - context, - LocaleKeys.settings_files_exportFileFail.tr() + - result.$2.join('\n'), - ); + if (mounted) { + if (result.$1) { + // success + showSnackBarMessage( + context, + LocaleKeys.settings_files_exportFileSuccess.tr(), + ); + } else { + showSnackBarMessage( + context, + LocaleKeys.settings_files_exportFileFail.tr() + + result.$2.join('\n'), + ); + } } } else { showSnackBarMessage( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_system_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_system_view.dart index fe13da536065..4adac5da0fcc 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_system_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_system_view.dart @@ -16,7 +16,7 @@ class _SettingsFileSystemViewState extends State { late final _items = [ const SettingsFileLocationCustomizer(), // disable export data for v0.2.0 in release mode. - if (kDebugMode) const SettingsExportFileWidget() + if (kDebugMode) const SettingsExportFileWidget(), ]; @override diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_notifications_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_notifications_view.dart index 3b9a40478e72..0d74175f1a45 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_notifications_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_notifications_view.dart @@ -33,7 +33,7 @@ class SettingsNotificationsView extends StatelessWidget { .read() .toggleNotificationsEnabled(); }, - ) + ), ], ), ], diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart index ee15bfbc318e..cf045d41b77f 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart @@ -56,7 +56,7 @@ class SettingsUserView extends StatelessWidget { _buildUserIconSetting(context), if (isCloudEnabled && user.authType != AuthTypePB.Local) ...[ const VSpace(12), - UserEmailInput(user.email) + UserEmailInput(user.email), ], const VSpace(12), _renderCurrentOpenaiKey(context), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/sync_setting_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/sync_setting_view.dart index 18bac66a11a9..f362a8e515ec 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/sync_setting_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/sync_setting_view.dart @@ -87,7 +87,7 @@ class EnableEncrypt extends StatelessWidget { .add(CloudSettingEvent.enableEncrypt(value)); }, value: state.config.enableEncrypt, - ) + ), ], ), Column( @@ -128,7 +128,7 @@ class EnableEncrypt extends StatelessWidget { ), ), ], - ) + ), ], ); }, @@ -154,7 +154,7 @@ class EnableSync extends StatelessWidget { ); }, value: state.config.enableSync, - ) + ), ], ); }, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart index 4a41460942d8..fc4778fe9f68 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart @@ -88,7 +88,7 @@ class _NavigatorTextFieldDialogState extends State { } Navigator.of(context).pop(); }, - ) + ), ], ), ); @@ -149,8 +149,8 @@ class _CreateFlowyAlertDialog extends State { widget.cancel?.call(); Navigator.of(context).pop(); }, - ) - ] + ), + ], ], ), ); @@ -209,7 +209,7 @@ class NavigatorOkCancelDialog extends StatelessWidget { }, okTitle: okTitle?.toUpperCase(), cancelTitle: cancelTitle?.toUpperCase(), - ) + ), ], ), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/left_bar_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/left_bar_item.dart index 7aeed651afe5..b30e5ef931bc 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/left_bar_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/left_bar_item.dart @@ -3,11 +3,13 @@ import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:flutter/material.dart'; +// TODO: Remove this file after the migration is done. class ViewLeftBarItem extends StatefulWidget { - final ViewPB view; + ViewLeftBarItem({ + required this.view, + }) : super(key: ValueKey(view.id)); - ViewLeftBarItem({required this.view, Key? key}) - : super(key: ValueKey(view.hashCode)); + final ViewPB view; @override State createState() => _ViewLeftBarItemState(); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/pop_up_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/pop_up_action.dart index 6fc14bda6472..182cab88f172 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/pop_up_action.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/pop_up_action.dart @@ -235,7 +235,7 @@ class HoverButton extends StatelessWidget { children: [ if (leftIcon != null) ...[ leftIcon!, - HSpace(ActionListSizes.itemHPadding) + HSpace(ActionListSizes.itemHPadding), ], Expanded( child: FlowyText.medium( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart new file mode 100644 index 000000000000..7d07f490f892 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart @@ -0,0 +1,282 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; +import 'package:appflowy/startup/tasks/app_window_size_manager.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-folder2/view.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +// workspaces / ... / view_title +class ViewTitleBar extends StatefulWidget { + const ViewTitleBar({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + State createState() => _ViewTitleBarState(); +} + +class _ViewTitleBarState extends State { + late Future> ancestors; + + @override + void initState() { + super.initState(); + + _reloadAncestors(); + } + + @override + void didUpdateWidget(covariant ViewTitleBar oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.view.id != widget.view.id) { + _reloadAncestors(); + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: ancestors, + builder: ((context, snapshot) { + final ancestors = snapshot.data; + if (ancestors == null) { + return const SizedBox.shrink(); + } + const maxWidth = WindowSizeManager.minWindowWidth - 100; + final replacement = Row( + // refresh the view title bar when the ancestors changed + key: ValueKey(ancestors.hashCode), + children: _buildViewTitles(ancestors), + ); + return LayoutBuilder( + builder: (context, constraints) { + return Visibility( + visible: constraints.maxWidth < maxWidth, + replacement: replacement, + // if the width is too small, only show one view title bar without the ancestors + child: _ViewTitle( + view: ancestors.last, + behavior: _ViewTitleBehavior.editable, + maxTitleWidth: constraints.maxWidth - 50.0, + onUpdated: () => setState(() => _reloadAncestors()), + ), + ); + }, + ); + }), + ); + } + + List _buildViewTitles(List views) { + // if the level is too deep, only show the last two view, the first one view and the root view + bool hasAddedEllipsis = false; + final children = []; + + for (var i = 0; i < views.length; i++) { + final view = views[i]; + if (i >= 1 && i < views.length - 2) { + if (!hasAddedEllipsis) { + hasAddedEllipsis = true; + children.add( + const FlowyText.regular(' ... /'), + ); + } + continue; + } + children.add( + _ViewTitle( + view: view, + behavior: i == views.length - 1 + ? _ViewTitleBehavior.editable // only the last one is editable + : _ViewTitleBehavior.uneditable, // others are not editable + onUpdated: () => setState(() => _reloadAncestors()), + ), + ); + if (i != views.length - 1) { + // if not the last one, add a divider + children.add(const FlowyText.regular('/')); + } + } + return children; + } + + void _reloadAncestors() { + ancestors = widget.view.getAncestors( + includeSelf: true, + ); + } +} + +enum _ViewTitleBehavior { + editable, + uneditable, +} + +class _ViewTitle extends StatefulWidget { + const _ViewTitle({ + required this.view, + this.behavior = _ViewTitleBehavior.editable, + this.maxTitleWidth = 180, + required this.onUpdated, + }); + + final ViewPB view; + final _ViewTitleBehavior behavior; + final double maxTitleWidth; + final VoidCallback onUpdated; + + @override + State<_ViewTitle> createState() => _ViewTitleState(); +} + +class _ViewTitleState extends State<_ViewTitle> { + final popoverController = PopoverController(); + final textEditingController = TextEditingController(); + late final viewListener = ViewListener(viewId: widget.view.id); + + String name = ''; + String icon = ''; + + @override + void initState() { + super.initState(); + + name = widget.view.name; + icon = widget.view.icon.value; + + _resetTextEditingController(); + viewListener.start( + onViewUpdated: (view) { + setState(() { + name = view.name; + icon = view.icon.value; + _resetTextEditingController(); + }); + widget.onUpdated(); + }, + ); + } + + @override + void dispose() { + textEditingController.dispose(); + popoverController.close(); + viewListener.stop(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // root view + if (widget.view.parentViewId.isEmpty) { + return Row( + children: [ + FlowyText.regular(name), + const HSpace(4.0), + ], + ); + } + + final child = Row( + children: [ + FlowyText.regular( + icon, + fontSize: 18.0, + ), + const HSpace(2.0), + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: widget.maxTitleWidth, + ), + child: FlowyText.regular( + name, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + + if (widget.behavior == _ViewTitleBehavior.uneditable) { + return FlowyButton( + useIntrinsicWidth: true, + onTap: () { + context.read().openPlugin(widget.view); + }, + text: child, + ); + } + + return AppFlowyPopover( + constraints: const BoxConstraints( + maxWidth: 300, + maxHeight: 44, + ), + controller: popoverController, + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 18), + popupBuilder: (context) { + // icon + textfield + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + EmojiPickerButton( + emoji: icon, + defaultIcon: widget.view.defaultIcon(), + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 18), + onSubmitted: (emoji, _) async { + await ViewBackendService.updateViewIcon( + viewId: widget.view.id, + viewIcon: emoji, + ); + popoverController.close(); + }, + ), + const HSpace(4.0), + SizedBox( + height: 36.0, + width: 220, + child: FlowyTextField( + autoFocus: true, + controller: textEditingController, + onSubmitted: (text) async { + if (text.isNotEmpty && text != name) { + await ViewBackendService.updateView( + viewId: widget.view.id, + name: text, + ); + popoverController.close(); + } + }, + ), + ), + const HSpace(4.0), + ], + ); + }, + child: FlowyButton( + useIntrinsicWidth: true, + text: child, + ), + ); + } + + void _resetTextEditingController() { + textEditingController + ..text = name + ..selection = TextSelection( + baseOffset: 0, + extentOffset: name.length, + ); + } +} diff --git a/frontend/appflowy_flutter/macos/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/macos/Runner.xcodeproj/project.pbxproj index 827d1150522c..0357a297b3e9 100644 --- a/frontend/appflowy_flutter/macos/Runner.xcodeproj/project.pbxproj +++ b/frontend/appflowy_flutter/macos/Runner.xcodeproj/project.pbxproj @@ -206,7 +206,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1430; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { @@ -421,7 +421,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; buildSettings = { - ARCHS = arm64; + ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; @@ -436,7 +436,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - ONLY_ACTIVE_ARCH = false; + ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.appflowy.flutter; PRODUCT_NAME = AppFlowy; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -558,7 +558,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; buildSettings = { - ARCHS = arm64; + ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; @@ -573,7 +573,6 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - ONLY_ACTIVE_ARCH = false; PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.appflowy.flutter; PRODUCT_NAME = AppFlowy; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -587,7 +586,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; buildSettings = { - ARCHS = arm64; + ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; @@ -602,7 +601,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - ONLY_ACTIVE_ARCH = false; + ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.appflowy.flutter; PRODUCT_NAME = AppFlowy; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/frontend/appflowy_flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/appflowy_flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 656a4d8b2a81..10d57fd28e3a 100644 --- a/frontend/appflowy_flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/frontend/appflowy_flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ init(Directory sdkDir) async { + Future init(Directory sdkDir, String env) async { final port = RustStreamReceiver.shared.port; ffi.set_stream_port(port); ffi.store_dart_post_cobject(NativeApi.postCObject); + ffi.set_env(env.toNativeUtf8()); ffi.init_sdk(sdkDir.path.toNativeUtf8()); } - - void setEnv(AppFlowyEnv env) { - final jsonStr = jsonEncode(env.toJson()); - ffi.set_env(jsonStr.toNativeUtf8()); - } } diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart index 2065e06beebd..af7a3766bdb1 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart @@ -176,10 +176,14 @@ class PopoverState extends State { _rootEntry.addEntry(context, this, newEntry, widget.asBarrier); } - void close() { + void close({ + bool notify = true, + }) { if (_rootEntry.contains(this)) { _rootEntry.removeEntry(this); - widget.onClose?.call(); + if (notify) { + widget.onClose?.call(); + } } } @@ -193,7 +197,7 @@ class PopoverState extends State { @override void deactivate() { - close(); + close(notify: false); super.deactivate(); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart index e577e098e521..684e876704fd 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart @@ -66,7 +66,7 @@ class DandelionColorScheme extends FlowyColorScheme { input: _white, hint: _lightShader3, primary: _lightDandelionYellow, - onPrimary: _white, + onPrimary: _lightShader1, // hover color in sidebar hoverBG1: _lightDandelionYellow, // tool bar hover color diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart index 23ba06198eda..894fc4c4b2ed 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart @@ -64,7 +64,7 @@ class LavenderColorScheme extends FlowyColorScheme { input: _white, hint: _lightShader3, primary: _lightMain1, - onPrimary: _white, + onPrimary: _lightShader1, hoverBG1: _lightBg2, hoverBG2: _lightHover, hoverBG3: _lightShader6, diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lemonade.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lemonade.dart index 7a1bd38f36d2..86a548059e1b 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lemonade.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lemonade.dart @@ -68,7 +68,7 @@ class LemonadeColorScheme extends FlowyColorScheme { input: _white, hint: _lightShader3, primary: _lightDandelionYellow, - onPrimary: _white, + onPrimary: _lightShader1, // hover color in sidebar hoverBG1: _lightDandelionYellow, // tool bar hover color diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_service.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_service.dart index 8039ea1e25d2..e8991397a9f8 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_service.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_service.dart @@ -1,8 +1,8 @@ +import 'package:file_picker/file_picker.dart'; + export 'package:file_picker/file_picker.dart' show FileType, FilePickerStatus, PlatformFile; -import 'package:file_picker/file_picker.dart'; - class FilePickerResult { const FilePickerResult(this.files); diff --git a/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml index 59de25ceb1c5..c812fcde8312 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml @@ -19,7 +19,7 @@ dependencies: uuid: ">=2.2.2" bloc: ^8.1.2 freezed_annotation: ^2.1.0 - file_picker: ^5.3.1 + file_picker: ^6.1.1 file: ^6.1.4 dev_dependencies: diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart index 906058e6d1d9..c9e21e273de7 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; @@ -24,6 +26,7 @@ class FlowyButton extends StatelessWidget { final Size? leftIconSize; final bool expandText; final MainAxisAlignment mainAxisAlignment; + final bool showDefaultBoxDecorationOnMobile; const FlowyButton({ Key? key, @@ -44,6 +47,7 @@ class FlowyButton extends StatelessWidget { this.leftIconSize = const Size.square(16), this.expandText = true, this.mainAxisAlignment = MainAxisAlignment.center, + this.showDefaultBoxDecorationOnMobile = false, }) : super(key: key); @override @@ -65,12 +69,12 @@ class FlowyButton extends StatelessWidget { ), onHover: disable ? null : onHover, isSelected: () => isSelected, - builder: (context, onHover) => _render(), + builder: (context, onHover) => _render(context), ), ); } - Widget _render() { + Widget _render(BuildContext context) { List children = List.empty(growable: true); if (leftIcon != null) { @@ -105,6 +109,16 @@ class FlowyButton extends StatelessWidget { child = IntrinsicWidth(child: child); } + final decoration = this.decoration ?? + (showDefaultBoxDecorationOnMobile && + (Platform.isIOS || Platform.isAndroid) + ? BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.surfaceVariant, + width: 1.0, + )) + : null); + return Container( decoration: decoration, child: Padding( diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart index 2f8ce2aaf5f3..318b0c95e989 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart @@ -70,7 +70,7 @@ class FlowyIconButton extends StatelessWidget { shape: RoundedRectangleBorder(borderRadius: radius ?? Corners.s6Border), fillColor: fillColor, - hoverColor: hoverColor, + hoverColor: Colors.transparent, focusColor: Colors.transparent, splashColor: Colors.transparent, highlightColor: Colors.transparent, @@ -79,17 +79,15 @@ class FlowyIconButton extends StatelessWidget { child: FlowyHover( isSelected: isSelected != null ? () => isSelected! : null, style: HoverStyle( - // hoverColor is set in both [HoverStyle] and [RawMaterialButton] to avoid the conflicts between two layers hoverColor: hoverColor, foregroundColorOnHover: iconColorOnHover ?? Theme.of(context).iconTheme.color, //Do not set background here. Use [fillColor] instead. ), + resetHoverOnRebuild: false, child: Padding( padding: iconPadding, - child: Center( - child: child, - ), + child: Center(child: child), ), ), ), @@ -99,11 +97,9 @@ class FlowyIconButton extends StatelessWidget { } class FlowyDropdownButton extends StatelessWidget { + const FlowyDropdownButton({super.key, this.onPressed}); + final VoidCallback? onPressed; - const FlowyDropdownButton({ - Key? key, - this.onPressed, - }) : super(key: key); @override Widget build(BuildContext context) { diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text.dart index 849538a36bdd..35d802984180 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; class FlowyText extends StatelessWidget { @@ -89,10 +91,12 @@ class FlowyText extends StatelessWidget { maxLines: maxLines, textAlign: textAlign, overflow: overflow ?? TextOverflow.clip, - textHeightBehavior: const TextHeightBehavior( - applyHeightToFirstAscent: false, - applyHeightToLastDescent: false, - ), + textHeightBehavior: Platform.isAndroid || Platform.isIOS + ? const TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + ) + : null, style: Theme.of(context).textTheme.bodyMedium!.copyWith( fontSize: fontSize, fontWeight: fontWeight, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart index 24135d92287f..0d40af1f8adc 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart @@ -22,6 +22,11 @@ class FlowyTextField extends StatefulWidget { final String? errorText; final int maxLines; final bool showCounter; + final Widget? prefixIcon; + final Widget? suffixIcon; + final BoxConstraints? prefixIconConstraints; + final BoxConstraints? suffixIconConstraints; + final BoxConstraints? hintTextConstraints; const FlowyTextField({ super.key, @@ -42,6 +47,11 @@ class FlowyTextField extends StatefulWidget { this.errorText, this.maxLines = 1, this.showCounter = true, + this.prefixIcon, + this.suffixIcon, + this.prefixIconConstraints, + this.suffixIconConstraints, + this.hintTextConstraints, }); @override @@ -55,6 +65,8 @@ class FlowyTextFieldState extends State { @override void initState() { + super.initState(); + focusNode = widget.focusNode ?? FocusNode(); focusNode.addListener(notifyDidEndEditing); @@ -66,11 +78,13 @@ class FlowyTextFieldState extends State { if (widget.autoFocus) { WidgetsBinding.instance.addPostFrameCallback((_) { focusNode.requestFocus(); - controller.selection = TextSelection.fromPosition( - TextPosition(offset: controller.text.length)); + if (widget.controller == null) { + controller.selection = TextSelection.fromPosition( + TextPosition(offset: controller.text.length), + ); + } }); } - super.initState(); } void _debounceOnChangedText(Duration duration, String text) { @@ -109,14 +123,22 @@ class FlowyTextFieldState extends State { }, onSubmitted: (text) => _onSubmitted(text), onEditingComplete: widget.onEditingComplete, + minLines: 1, maxLines: widget.maxLines, maxLength: widget.maxLength, maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds, style: widget.textStyle ?? Theme.of(context).textTheme.bodySmall, + textAlignVertical: TextAlignVertical.center, + keyboardType: TextInputType.multiline, decoration: InputDecoration( - constraints: BoxConstraints( - maxHeight: widget.errorText?.isEmpty ?? true ? 32 : 58), - contentPadding: const EdgeInsets.symmetric(horizontal: 12), + constraints: widget.hintTextConstraints ?? + BoxConstraints( + maxHeight: widget.errorText?.isEmpty ?? true ? 32 : 58, + ), + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: widget.maxLines > 1 ? 12 : 0, + ), enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.outline, @@ -158,6 +180,10 @@ class FlowyTextFieldState extends State { ), borderRadius: Corners.s8Border, ), + prefixIcon: widget.prefixIcon, + suffixIcon: widget.suffixIcon, + prefixIconConstraints: widget.prefixIconConstraints, + suffixIconConstraints: widget.suffixIconConstraints, ), ); } diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 1eed0a9fa0b3..3498b75a9571 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -45,19 +45,20 @@ packages: dependency: "direct main" description: path: "." - ref: a183c57 - resolved-ref: a183c57013071cb3192fcf3c9b1eeb89462179b7 + ref: "2de4fe0" + resolved-ref: "2de4fe0b0245dcdf2c2bf43410661c28acbcc687" url: "https://github.com/AppFlowy-IO/appflowy-board.git" source: git - version: "0.0.9" + version: "0.1.1" appflowy_editor: dependency: "direct main" description: - name: appflowy_editor - sha256: cb6a0e7fa545923495cf85f1173b9f5572c67dc2f003b8d2b6dec9305eb5dafa - url: "https://pub.dev" - source: hosted - version: "1.5.0" + path: "." + ref: "4f073f3" + resolved-ref: "4f073f3381a05a2379144f282c6f65462c4ce9c6" + url: "https://github.com/AppFlowy-IO/appflowy-editor.git" + source: git + version: "2.0.0-beta.1" appflowy_popover: dependency: "direct main" description: @@ -177,6 +178,30 @@ packages: url: "https://pub.dev" source: hosted version: "8.6.0" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: f98972704692ba679db144261172a8e20feb145636c617af0eb4022132a6797f + url: "https://pub.dev" + source: hosted + version: "3.3.0" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "56aa42a7a01e3c9db8456d9f3f999931f1e05535b5a424271e9a38cabf066613" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "759b9a9f8f6ccbb66c185df805fac107f05730b1dab9c64626d1008cca532257" + url: "https://pub.dev" + source: hosted + version: "1.1.0" calendar_view: dependency: "direct main" description: @@ -237,10 +262,10 @@ packages: dependency: "direct main" description: name: collection - sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" + sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 url: "https://pub.dev" source: hosted - version: "1.17.1" + version: "1.17.2" connectivity_plus: dependency: "direct main" description: @@ -273,6 +298,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.3" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "445db18de832dba8d851e287aff8ccf169bed30d2e94243cb54c7d2f1ed2142c" + url: "https://pub.dev" + source: hosted + version: "0.3.3+6" crypto: dependency: transitive description: @@ -345,6 +378,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0+3" + easy_debounce: + dependency: transitive + description: + name: easy_debounce + sha256: f082609cfb8f37defb9e37fc28bc978c6712dedf08d4c5a26f820fa10165a236 + url: "https://pub.dev" + source: hosted + version: "2.0.3" easy_localization: dependency: "direct main" description: @@ -421,10 +462,42 @@ packages: dependency: transitive description: name: file_picker - sha256: "9d6e95ec73abbd31ec54d0e0df8a961017e165aba1395e462e5b31ea0c165daf" + sha256: "4e42aacde3b993c5947467ab640882c56947d9d27342a5b6f2895b23956954a6" + url: "https://pub.dev" + source: hosted + version: "6.1.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "045d372bf19b02aeb69cacf8b4009555fb5f6f0b7ad8016e5f46dd1387ddd492" + url: "https://pub.dev" + source: hosted + version: "0.9.2+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: b15c3da8bd4908b9918111fa486903f5808e388b8d1c559949f584725a6594d6 url: "https://pub.dev" source: hosted - version: "5.3.1" + version: "0.9.3+3" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "0aa47a725c346825a2bd396343ce63ac00bda6eff2fbc43eabe99737dede8262" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0 + url: "https://pub.dev" + source: hosted + version: "0.9.3+1" fixnum: dependency: "direct main" description: @@ -481,6 +554,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.3" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba" + url: "https://pub.dev" + source: hosted + version: "3.3.1" flutter_colorpicker: dependency: "direct main" description: @@ -494,6 +575,15 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_emoji_mart: + dependency: "direct main" + description: + path: "." + ref: "140b530" + resolved-ref: "140b53091ce7ad971e97c1d5a53fe0875596326d" + url: "https://github.com/LucasXu0/emoji_mart.git" + source: git + version: "1.0.2" flutter_lints: dependency: "direct dev" description: @@ -532,6 +622,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + flutter_sticky_header: + dependency: transitive + description: + name: flutter_sticky_header + sha256: "017f398fbb45a589e01491861ca20eb6570a763fd9f3888165a978e11248c709" + url: "https://pub.dev" + source: hosted + version: "0.6.5" flutter_svg: dependency: "direct main" description: @@ -570,10 +668,10 @@ packages: dependency: "direct main" description: name: freezed_annotation - sha256: aeac15850ef1b38ee368d4c53ba9a847e900bb2c53a4db3f6881cbb3cb684338 + sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.1" frontend_server_client: dependency: transitive description: @@ -715,6 +813,78 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + image_gallery_saver: + dependency: "direct main" + description: + name: image_gallery_saver + sha256: "0aba74216a4d9b0561510cb968015d56b701ba1bd94aace26aacdd8ae5761816" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "7d7f2768df2a8b0a3cefa5ef4f84636121987d403130e70b17ef7e2cf650ba84" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: d6a6e78821086b0b737009b09363018309bbc6de3fd88cc5c26bc2bb44a4957f + url: "https://pub.dev" + source: hosted + version: "0.8.8+2" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "50bc9ae6a77eea3a8b11af5eb6c661eeb858fdd2f734c2a4fd17086922347ef7" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "76ec722aeea419d03aa915c2c96bf5b47214b053899088c9abb4086ceecf97a7" + url: "https://pub.dev" + source: hosted + version: "0.8.8+4" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: ed9b00e63977c93b0d2d2b343685bed9c324534ba5abafbb3dfbd6a780b1b514 + url: "https://pub.dev" + source: hosted + version: "2.9.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" integration_test: dependency: "direct dev" description: flutter @@ -724,10 +894,10 @@ packages: dependency: "direct main" description: name: intl - sha256: a3715e3bc90294e971cb7dc063fbf3cd9ee0ebf8604ffeafabd9e6f16abbdbe6 + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" url: "https://pub.dev" source: hosted - version: "0.18.0" + version: "0.18.1" intl_utils: dependency: transitive description: @@ -868,18 +1038,18 @@ packages: dependency: transitive description: name: matcher - sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb" + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" url: "https://pub.dev" source: hosted - version: "0.12.15" + version: "0.12.16" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.5.0" meta: dependency: transitive description: @@ -944,6 +1114,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" + url: "https://pub.dev" + source: hosted + version: "2.0.0" package_config: dependency: transitive description: @@ -1036,10 +1214,10 @@ packages: dependency: transitive description: name: path_provider_windows - sha256: d3f80b32e83ec208ac95253e0cd4d298e104fbc63cb29c5c69edaed43b0c69d6 + sha256: "1cb68ba4cd3a795033de62ba1b7b4564dace301f952de6bfb3cd91b202b6ee96" url: "https://pub.dev" source: hosted - version: "2.1.6" + version: "2.1.7" percent_indicator: dependency: "direct main" description: @@ -1228,10 +1406,10 @@ packages: dependency: transitive description: name: rich_clipboard_windows - sha256: fa2a28e75ce4bcc9efc6d5d0e9788b76716cdaf3b7063c141fe8af12a315f414 + sha256: "633198bcd74642bb03c4a628c7e350ee18bb391cd8c6132152f7c97ab250e901" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.0.3" run_with_network_images: dependency: "direct dev" description: @@ -1389,6 +1567,14 @@ packages: description: flutter source: sdk version: "0.0.99" + sliver_tools: + dependency: transitive + description: + name: sliver_tools + sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 + url: "https://pub.dev" + source: hosted + version: "0.2.12" source_gen: dependency: transitive description: @@ -1425,10 +1611,26 @@ packages: dependency: transitive description: name: source_span - sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.10.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "591f1602816e9c31377d5f008c2d9ef7b8aca8941c3f89cc5fd9d84da0c38a9a" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "8ed044102f3135add97be8653662052838859f5400075ef227f8ad72ae320803" + url: "https://pub.dev" + source: hosted + version: "2.5.0+1" stack_trace: dependency: transitive description: @@ -1526,6 +1728,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" + url: "https://pub.dev" + source: hosted + version: "3.1.0" table_calendar: dependency: "direct main" description: @@ -1546,26 +1756,26 @@ packages: dependency: transitive description: name: test - sha256: "3dac9aecf2c3991d09b9cdde4f98ded7b30804a88a0d7e4e7e1678e78d6b97f4" + sha256: "13b41f318e2a5751c3169137103b60c584297353d4b1761b66029bae6411fe46" url: "https://pub.dev" source: hosted - version: "1.24.1" + version: "1.24.3" test_api: dependency: transitive description: name: test_api - sha256: eb6ac1540b26de412b3403a163d919ba86f6a973fe6cc50ae3541b80092fdcfb + sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "0.6.0" test_core: dependency: transitive description: name: test_core - sha256: "5138dbffb77b2289ecb12b81c11ba46036590b72a64a7a90d6ffb880f1a29e93" + sha256: "99806e9e6d95c7b059b7a0fc08f07fc53fabe54a829497f0d9676299f1e8637e" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "0.5.3" textfield_tags: dependency: "direct main" description: @@ -1727,6 +1937,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + value_layout_builder: + dependency: transitive + description: + name: value_layout_builder + sha256: "98202ec1807e94ac72725b7f0d15027afde513c55c69ff3f41bcfccb950831bc" + url: "https://pub.dev" + source: hosted + version: "0.3.1" vector_graphics: dependency: transitive description: @@ -1771,10 +1989,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f6deed8ed625c52864792459709183da231ebf66ff0cf09e69b573227c377efe + sha256: c620a6f783fa22436da68e42db7ebbf18b8c44b9a46ab911f666ff09ffd9153f url: "https://pub.dev" source: hosted - version: "11.3.0" + version: "11.7.1" watcher: dependency: transitive description: @@ -1783,6 +2001,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 + url: "https://pub.dev" + source: hosted + version: "0.1.4-beta" web_socket_channel: dependency: transitive description: @@ -1843,18 +2069,18 @@ packages: dependency: transitive description: name: win32 - sha256: "5a751eddf9db89b3e5f9d50c20ab8612296e4e8db69009788d6c8b060a84191c" + sha256: f2add6fa510d3ae152903412227bda57d0d5a8da61d2c39c1fb022c9429a41c0 url: "https://pub.dev" source: hosted - version: "4.1.4" + version: "5.0.6" win32_registry: dependency: transitive description: name: win32_registry - sha256: "1c52f994bdccb77103a6231ad4ea331a244dbcef5d1f37d8462f713143b0bfae" + sha256: e4506d60b7244251bc59df15656a3093501c37fb5af02105a944d73eb95be4c9 url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" window_manager: dependency: "direct main" description: @@ -1896,5 +2122,5 @@ packages: source: hosted version: "1.1.1" sdks: - dart: ">=3.0.0 <4.0.0" - flutter: ">=3.10.1" + dart: ">=3.1.5 <4.0.0" + flutter: ">=3.13.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 847619d30bc9..691a34502db7 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -15,10 +15,10 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.3.7 +version: 0.3.8 environment: - sdk: ">=3.0.0 <4.0.0" + sdk: ">=3.1.5 <4.0.0" # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -43,8 +43,12 @@ dependencies: # path: packages/appflowy_board git: url: https://github.com/AppFlowy-IO/appflowy-board.git - ref: a183c57 - appflowy_editor: ^1.5.0 + ref: 2de4fe0 + appflowy_editor: + git: + url: https://github.com/AppFlowy-IO/appflowy-editor.git + ref: 4f073f3 + appflowy_popover: path: packages/appflowy_popover @@ -108,6 +112,10 @@ dependencies: go_router: ^10.1.2 string_validator: ^1.0.0 unsplash_client: ^2.1.1 + flutter_emoji_mart: + git: + url: https://github.com/LucasXu0/emoji_mart.git + ref: "140b530" # Notifications # TODO: Consider implementing custom package @@ -115,6 +123,9 @@ dependencies: local_notifier: ^0.1.5 app_links: ^3.4.1 flutter_slidable: ^3.0.0 + image_picker: ^1.0.4 + image_gallery_saver: ^2.0.3 + cached_network_image: ^3.3.0 dev_dependencies: flutter_lints: ^2.0.1 @@ -193,6 +204,11 @@ flutter: weight: 800 - asset: assets/google_fonts/Poppins/Poppins-ExtraBold.ttf weight: 900 + - family: RobotoMono + fonts: + - asset: assets/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf + - asset: assets/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf + style: italic # To add assets to your application, add an assets section, like this: assets: diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_multi_select_field_test.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_multi_select_field_test.dart index a73e05f224f5..bd8399b1dfd7 100644 --- a/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_multi_select_field_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_multi_select_field_test.dart @@ -107,8 +107,8 @@ void main() { final groups = boardBloc.groupControllers.values.map((e) => e.group).toList(); - assert(groups[0].groupName == "B"); - assert(groups[1].groupName == "A"); - assert(groups[2].groupName == "No ${multiSelectField.name}"); + assert(groups[0].groupName == "No ${multiSelectField.name}"); + assert(groups[1].groupName == "B"); + assert(groups[2].groupName == "A"); }); } diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart index d9c11130fea5..0e4b92b9e85b 100644 --- a/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart @@ -3,7 +3,6 @@ import 'package:appflowy/plugins/database_view/application/cell/cell_controller_ import 'package:appflowy/plugins/database_view/application/field/field_controller.dart'; import 'package:appflowy/plugins/database_view/application/field/field_editor_bloc.dart'; import 'package:appflowy/plugins/database_view/application/field/field_info.dart'; -import 'package:appflowy/plugins/database_view/application/field/field_service.dart'; import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart'; import 'package:appflowy/plugins/database_view/application/row/row_cache.dart'; import 'package:appflowy/plugins/database_view/application/row/row_controller.dart'; @@ -141,11 +140,6 @@ class BoardTestContext { return fieldInfo; } - FieldContext singleSelectFieldCellContext() { - final fieldInfo = singleSelectFieldContext(); - return FieldContext(viewId: gridView.id, fieldInfo: fieldInfo); - } - FieldInfo textFieldContext() { final fieldInfo = fieldContexts .firstWhere((element) => element.fieldType == FieldType.RichText); diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/field/field_cell_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/field/field_cell_bloc_test.dart index e36b9587c825..3aa656f39392 100644 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/field/field_cell_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/field/field_cell_bloc_test.dart @@ -1,5 +1,4 @@ import 'package:appflowy/plugins/database_view/application/field/field_cell_bloc.dart'; -import 'package:appflowy/plugins/database_view/application/field/field_service.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -23,11 +22,9 @@ void main() { blocTest( 'update field width', build: () => FieldCellBloc( - fieldContext: FieldContext( - fieldInfo: context.fieldContexts[0], - viewId: context.gridView.id, - ), - )..add(const FieldCellEvent.initial()), + fieldInfo: context.fieldContexts[0], + viewId: context.gridView.id, + ), act: (bloc) { width = bloc.state.width; bloc.add(const FieldCellEvent.onResizeStart()); @@ -42,11 +39,9 @@ void main() { blocTest( 'field width should not be lesser than 50px', build: () => FieldCellBloc( - fieldContext: FieldContext( - fieldInfo: context.fieldContexts[0], - viewId: context.gridView.id, - ), - )..add(const FieldCellEvent.initial()), + viewId: context.gridView.id, + fieldInfo: context.fieldContexts[0], + ), act: (bloc) { bloc.add(const FieldCellEvent.onResizeStart()); bloc.add(const FieldCellEvent.startUpdateWidth(-110)); diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/grid_header_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/grid_header_bloc_test.dart index 571cb11d894a..29bb34d7b648 100644 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/grid_header_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/grid_header_bloc_test.dart @@ -18,7 +18,8 @@ void main() { setUp(() async { context = await gridTest.createTestGrid(); actionSheetBloc = FieldActionSheetBloc( - fieldCellContext: context.singleSelectFieldCellContext(), + viewId: context.gridView.id, + fieldInfo: context.singleSelectFieldContext(), ); }); diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/util.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/util.dart index 52a981fcb6ea..7df94f39440c 100644 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/util.dart +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/util.dart @@ -3,7 +3,6 @@ import 'package:appflowy/plugins/database_view/application/cell/cell_controller_ import 'package:appflowy/plugins/database_view/application/field/field_controller.dart'; import 'package:appflowy/plugins/database_view/application/field/field_editor_bloc.dart'; import 'package:appflowy/plugins/database_view/application/field/field_info.dart'; -import 'package:appflowy/plugins/database_view/application/field/field_service.dart'; import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart'; import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_service.dart'; import 'package:appflowy/plugins/database_view/application/row/row_cache.dart'; @@ -88,11 +87,6 @@ class GridTestContext { return fieldInfo; } - FieldContext singleSelectFieldCellContext() { - final fieldInfo = singleSelectFieldContext(); - return FieldContext(viewId: gridView.id, fieldInfo: fieldInfo); - } - FieldInfo textFieldContext() { final fieldInfo = fieldContexts .firstWhere((element) => element.fieldType == FieldType.RichText); diff --git a/frontend/appflowy_flutter/test/bloc_test/home_test/app_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/home_test/app_bloc_test.dart index 8913709d4c4e..89d58cbd54ca 100644 --- a/frontend/appflowy_flutter/test/bloc_test/home_test/app_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/home_test/app_bloc_test.dart @@ -126,7 +126,7 @@ void main() { ..add(const DocumentEvent.initial()); await blocResponseFuture(); - final workspaceSetting = await FolderEventGetCurrentWorkspace() + final workspaceSetting = await FolderEventGetCurrentWorkspaceSetting() .send() .then((result) => result.fold((l) => l, (r) => throw Exception())); workspaceSetting.latestView.id == document1.id; @@ -147,7 +147,7 @@ void main() { final grid = bloc.state.latestCreatedView; assert(grid!.name == "grid 2"); - var workspaceSetting = await FolderEventGetCurrentWorkspace() + var workspaceSetting = await FolderEventGetCurrentWorkspaceSetting() .send() .then((result) => result.fold((l) => l, (r) => throw Exception())); workspaceSetting.latestView.id == grid!.id; @@ -158,7 +158,7 @@ void main() { ..add(const DocumentEvent.initial()); await blocResponseFuture(); - workspaceSetting = await FolderEventGetCurrentWorkspace() + workspaceSetting = await FolderEventGetCurrentWorkspaceSetting() .send() .then((result) => result.fold((l) => l, (r) => throw Exception())); workspaceSetting.latestView.id == document.id; diff --git a/frontend/appflowy_flutter/test/bloc_test/home_test/home_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/home_test/home_bloc_test.dart index 7d9f158b6981..2b17e496441e 100644 --- a/frontend/appflowy_flutter/test/bloc_test/home_test/home_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/home_test/home_bloc_test.dart @@ -14,7 +14,7 @@ void main() { }); test('initi home screen', () async { - final workspaceSetting = await FolderEventGetCurrentWorkspace() + final workspaceSetting = await FolderEventGetCurrentWorkspaceSetting() .send() .then((result) => result.fold((l) => l, (r) => throw Exception())); await blocResponseFuture(); @@ -27,7 +27,7 @@ void main() { }); test('open the document', () async { - final workspaceSetting = await FolderEventGetCurrentWorkspace() + final workspaceSetting = await FolderEventGetCurrentWorkspaceSetting() .send() .then((result) => result.fold((l) => l, (r) => throw Exception())); await blocResponseFuture(); @@ -52,6 +52,7 @@ void main() { await FolderEventSetLatestView(ViewIdPB(value: latestView.id)).send(); await blocResponseFuture(); - assert(homeBloc.state.workspaceSetting.latestView.id == latestView.id); + final actual = homeBloc.state.workspaceSetting.latestView.id; + assert(actual == latestView.id); }); } diff --git a/frontend/appflowy_flutter/test/bloc_test/home_test/menu_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/home_test/menu_bloc_test.dart index abbdeaa50506..cae6493ed41a 100644 --- a/frontend/appflowy_flutter/test/bloc_test/home_test/menu_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/home_test/menu_bloc_test.dart @@ -12,7 +12,7 @@ void main() { test('assert initial apps is the build-in app', () async { final menuBloc = MenuBloc( user: testContext.userProfile, - workspace: testContext.currentWorkspace, + workspaceId: testContext.currentWorkspace.id, )..add(const MenuEvent.initial()); await blocResponseFuture(); @@ -22,7 +22,7 @@ void main() { test('reorder apps', () async { final menuBloc = MenuBloc( user: testContext.userProfile, - workspace: testContext.currentWorkspace, + workspaceId: testContext.currentWorkspace.id, )..add(const MenuEvent.initial()); await blocResponseFuture(); menuBloc.add(const MenuEvent.createApp("App 1")); diff --git a/frontend/appflowy_flutter/test/bloc_test/shortcuts_test/shortcuts_cubit_test.dart b/frontend/appflowy_flutter/test/bloc_test/shortcuts_test/shortcuts_cubit_test.dart index 3d88d5fbe503..1b896cbd3dd3 100644 --- a/frontend/appflowy_flutter/test/bloc_test/shortcuts_test/shortcuts_cubit_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/shortcuts_test/shortcuts_cubit_test.dart @@ -58,7 +58,7 @@ void main() { expect: () => [ const ShortcutsState(status: ShortcutsStatus.updating), isA() - .having((w) => w.status, 'status', ShortcutsStatus.failure) + .having((w) => w.status, 'status', ShortcutsStatus.failure), ], ); @@ -101,7 +101,7 @@ void main() { expect: () => [ const ShortcutsState(status: ShortcutsStatus.updating), isA() - .having((w) => w.status, 'status', ShortcutsStatus.failure) + .having((w) => w.status, 'status', ShortcutsStatus.failure), ], ); @@ -112,7 +112,7 @@ void main() { expect: () => [ const ShortcutsState(status: ShortcutsStatus.updating), isA() - .having((w) => w.status, 'status', ShortcutsStatus.success) + .having((w) => w.status, 'status', ShortcutsStatus.success), ], ); }); @@ -140,7 +140,7 @@ void main() { expect: () => [ const ShortcutsState(status: ShortcutsStatus.updating), isA() - .having((w) => w.status, 'status', ShortcutsStatus.failure) + .having((w) => w.status, 'status', ShortcutsStatus.failure), ], ); diff --git a/frontend/appflowy_flutter/test/unit_test/editor/transaction_adapter_test.dart b/frontend/appflowy_flutter/test/unit_test/editor/transaction_adapter_test.dart index 3649a3b6139c..7f25a55353c9 100644 --- a/frontend/appflowy_flutter/test/unit_test/editor/transaction_adapter_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/editor/transaction_adapter_test.dart @@ -108,7 +108,7 @@ void main() { root: Node( type: 'page', children: [ - paragraphNode(children: [paragraphNode(text: '1')]) + paragraphNode(children: [paragraphNode(text: '1')]), ], ), ); diff --git a/frontend/appflowy_flutter/test/util.dart b/frontend/appflowy_flutter/test/util.dart index 0b86a9c3310b..55e5a7db6249 100644 --- a/frontend/appflowy_flutter/test/util.dart +++ b/frontend/appflowy_flutter/test/util.dart @@ -27,7 +27,7 @@ class AppFlowyUnitTest { late UserProfilePB userProfile; late UserBackendService userService; late WorkspaceService workspaceService; - late List workspaces; + late WorkspacePB workspace; static Future ensureInitialized() async { TestWidgetsFlutterBinding.ensureInitialized(); @@ -68,12 +68,12 @@ class AppFlowyUnitTest { ); } - WorkspacePB get currentWorkspace => workspaces[0]; + WorkspacePB get currentWorkspace => workspace; Future _loadWorkspace() async { - final result = await userService.getWorkspaces(); + final result = await userService.getCurrentWorkspace(); result.fold( - (value) => workspaces = value, + (value) => workspace = value, (error) { throw Exception(error); }, diff --git a/frontend/appflowy_tauri/.eslintrc.cjs b/frontend/appflowy_tauri/.eslintrc.cjs index 7223e5cb39d9..2047d4f962d3 100644 --- a/frontend/appflowy_tauri/.eslintrc.cjs +++ b/frontend/appflowy_tauri/.eslintrc.cjs @@ -15,20 +15,20 @@ module.exports = { plugins: ['@typescript-eslint', "react-hooks"], rules: { "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "warn", + "react-hooks/exhaustive-deps": "error", '@typescript-eslint/adjacent-overload-signatures': 'error', '@typescript-eslint/no-empty-function': 'error', - '@typescript-eslint/no-empty-interface': 'warn', - '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-empty-interface': 'error', + '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/await-thenable': 'error', '@typescript-eslint/no-namespace': 'error', '@typescript-eslint/no-unnecessary-type-assertion': 'error', '@typescript-eslint/no-redeclare': 'error', - '@typescript-eslint/prefer-for-of': 'warn', + '@typescript-eslint/prefer-for-of': 'error', '@typescript-eslint/triple-slash-reference': 'error', - '@typescript-eslint/unified-signatures': 'warn', + '@typescript-eslint/unified-signatures': 'error', 'no-shadow': 'off', - '@typescript-eslint/no-shadow': 'warn', + '@typescript-eslint/no-shadow': 'off', 'constructor-super': 'error', eqeqeq: ['error', 'always'], 'no-cond-assign': 'error', @@ -47,18 +47,18 @@ module.exports = { 'no-throw-literal': 'error', 'no-unsafe-finally': 'error', 'no-unused-labels': 'error', - 'no-var': 'warn', + 'no-var': 'error', 'no-void': 'off', - 'prefer-const': 'warn', + 'prefer-const': 'error', 'prefer-spread': 'off', '@typescript-eslint/no-unused-vars': [ - 'warn', + 'error', { argsIgnorePattern: '^_', } ], 'padding-line-between-statements': [ - "warn", + "error", { blankLine: "always", prev: ["const", "let", "var"], next: "*"}, { blankLine: "any", prev: ["const", "let", "var"], next: ["const", "let", "var"]}, { blankLine: "always", prev: "import", next: "*" }, diff --git a/frontend/appflowy_tauri/package.json b/frontend/appflowy_tauri/package.json index 6c65dae94d04..53e5bde53a42 100644 --- a/frontend/appflowy_tauri/package.json +++ b/frontend/appflowy_tauri/package.json @@ -9,7 +9,7 @@ "preview": "vite preview", "format": "prettier --write .", "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .", - "test:errors": "pnpm sync:i18n && tsc --noEmit && eslint --quiet --ext .js,.ts,.tsx .", + "test:errors": "pnpm sync:i18n && tsc --noEmit && eslint --ext .js,.ts,.tsx .", "test:prettier": "pnpm prettier --list-different src", "tauri:clean": "cargo make --cwd .. tauri_clean", "tauri:dev": "pnpm sync:i18n && tauri dev", @@ -29,6 +29,7 @@ "@slate-yjs/core": "^1.0.0", "@tanstack/react-virtual": "3.0.0-beta.54", "@tauri-apps/api": "^1.2.0", + "@types/react-swipeable-views": "^0.13.4", "dayjs": "^1.11.9", "emoji-mart": "^5.5.2", "emoji-regex": "^10.2.1", @@ -40,6 +41,7 @@ "is-hotkey": "^0.2.0", "jest": "^29.5.0", "katex": "^0.16.7", + "lodash-es": "^4.17.21", "nanoid": "^4.0.0", "prismjs": "^1.29.0", "protoc-gen-ts": "^0.8.5", @@ -55,6 +57,7 @@ "react-katex": "^3.0.1", "react-redux": "^8.0.5", "react-router-dom": "^6.8.0", + "react-swipeable-views": "^0.14.0", "react-transition-group": "^4.4.5", "react18-input-otp": "^1.1.2", "redux": "^4.2.1", @@ -73,6 +76,7 @@ "@types/is-hotkey": "^0.1.7", "@types/jest": "^29.5.3", "@types/katex": "^0.16.0", + "@types/lodash-es": "^4.17.11", "@types/node": "^18.7.10", "@types/prismjs": "^1.26.0", "@types/quill": "^2.0.10", diff --git a/frontend/appflowy_tauri/pnpm-lock.yaml b/frontend/appflowy_tauri/pnpm-lock.yaml index 2eceb509243e..9c13ac1f54f8 100644 --- a/frontend/appflowy_tauri/pnpm-lock.yaml +++ b/frontend/appflowy_tauri/pnpm-lock.yaml @@ -34,6 +34,9 @@ dependencies: '@tauri-apps/api': specifier: ^1.2.0 version: 1.3.0 + '@types/react-swipeable-views': + specifier: ^0.13.4 + version: 0.13.4 dayjs: specifier: ^1.11.9 version: 1.11.9 @@ -67,6 +70,9 @@ dependencies: katex: specifier: ^0.16.7 version: 0.16.7 + lodash-es: + specifier: ^4.17.21 + version: 4.17.21 nanoid: specifier: ^4.0.0 version: 4.0.2 @@ -112,6 +118,9 @@ dependencies: react-router-dom: specifier: ^6.8.0 version: 6.11.1(react-dom@18.2.0)(react@18.2.0) + react-swipeable-views: + specifier: ^0.14.0 + version: 0.14.0(react@18.2.0) react-transition-group: specifier: ^4.4.5 version: 4.4.5(react-dom@18.2.0)(react@18.2.0) @@ -162,6 +171,9 @@ devDependencies: '@types/katex': specifier: ^0.16.0 version: 0.16.0 + '@types/lodash-es': + specifier: ^4.17.11 + version: 4.17.11 '@types/node': specifier: ^18.7.10 version: 18.16.9 @@ -553,6 +565,12 @@ packages: '@babel/helper-plugin-utils': 7.21.5 dev: true + /@babel/runtime@7.0.0: + resolution: {integrity: sha512-7hGhzlcmg01CvH1EHdSPVXYX1aJ8KCEyz6I9xYIi/asDtzBPMyMhVibhM/K6g/5qnKBwjZtp10bNZIEFTRW1MA==} + dependencies: + regenerator-runtime: 0.12.1 + dev: false + /@babel/runtime@7.21.5: resolution: {integrity: sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==} engines: {node: '>=6.9.0'} @@ -1951,6 +1969,12 @@ packages: resolution: {integrity: sha512-hz+S3nV6Mym5xPbT9fnO8dDhBFQguMYpY0Ipxv06JMi1ORgnEM4M1ymWDUhUNer3ElLmT583opRo4RzxKmh9jw==} dev: true + /@types/lodash-es@4.17.11: + resolution: {integrity: sha512-eCw8FYAWHt2DDl77s+AMLLzPn310LKohruumpucZI4oOFJkIgnlaJcy23OKMJxx4r9PeTF13Gv6w+jqjWQaYUg==} + dependencies: + '@types/lodash': 4.14.194 + dev: true + /@types/lodash.memoize@4.1.7: resolution: {integrity: sha512-lGN7WeO4vO6sICVpf041Q7BX/9k1Y24Zo3FY0aUezr1QlKznpjzsDk3T3wvH8ofYzoK0QupN9TWcFAFZlyPwQQ==} dependencies: @@ -1959,7 +1983,6 @@ packages: /@types/lodash@4.14.194: resolution: {integrity: sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==} - dev: false /@types/node@18.16.9: resolution: {integrity: sha512-IeB32oIV4oGArLrd7znD2rkHQ6EDCM+2Sr76dJnrHwv9OHBTTM6nuDLK9bmikXzPa0ZlWMWtRGo/Uw4mrzQedA==} @@ -2030,6 +2053,12 @@ packages: redux: 4.2.1 dev: false + /@types/react-swipeable-views@0.13.4: + resolution: {integrity: sha512-hQV9Oq6oa+9HKdnGd43xkckElwf5dThOiegtQxqE7qX761oHhxnZO07fz6IsKSnUy9J3tzlRQBu3sNyvC8+kYw==} + dependencies: + '@types/react': 18.2.6 + dev: false + /@types/react-transition-group@4.4.6: resolution: {integrity: sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==} dependencies: @@ -2460,7 +2489,7 @@ packages: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} dependencies: - '@babel/runtime': 7.21.5 + '@babel/runtime': 7.22.10 cosmiconfig: 7.1.0 resolve: 1.22.2 dev: false @@ -4557,6 +4586,10 @@ packages: commander: 8.3.0 dev: false + /keycode@2.2.1: + resolution: {integrity: sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==} + dev: false + /kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -5317,6 +5350,17 @@ packages: react: 18.2.0 dev: false + /react-event-listener@0.6.6(react@18.2.0): + resolution: {integrity: sha512-+hCNqfy7o9wvO6UgjqFmBzARJS7qrNoda0VqzvOuioEpoEXKutiKuv92dSz6kP7rYLmyHPyYNLesi5t/aH1gfw==} + peerDependencies: + react: ^16.3.0 + dependencies: + '@babel/runtime': 7.22.10 + prop-types: 15.8.1 + react: 18.2.0 + warning: 4.0.3 + dev: false + /react-i18next@12.2.2(i18next@22.4.15)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-KBB6buBmVKXUWNxXHdnthp+38gPyBT46hJCAIQ8rX19NFL/m2ahte2KARfIDf2tMnSAL7wwck6eDOd/9zn6aFg==} peerDependencies: @@ -5442,6 +5486,42 @@ packages: react: 18.2.0 dev: false + /react-swipeable-views-core@0.14.0: + resolution: {integrity: sha512-0W/e9uPweNEOSPjmYtuKSC/SvKKg1sfo+WtPdnxeLF3t2L82h7jjszuOHz9C23fzkvLfdgkaOmcbAxE9w2GEjA==} + engines: {node: '>=6.0.0'} + dependencies: + '@babel/runtime': 7.0.0 + warning: 4.0.3 + dev: false + + /react-swipeable-views-utils@0.14.0(react@18.2.0): + resolution: {integrity: sha512-W+fXBOsDqgFK1/g7MzRMVcDurp3LqO3ksC8UgInh2P/tKgb5DusuuB1geKHFc6o1wKl+4oyER4Zh3Lxmr8xbXA==} + engines: {node: '>=6.0.0'} + dependencies: + '@babel/runtime': 7.0.0 + keycode: 2.2.1 + prop-types: 15.8.1 + react-event-listener: 0.6.6(react@18.2.0) + react-swipeable-views-core: 0.14.0 + shallow-equal: 1.2.1 + transitivePeerDependencies: + - react + dev: false + + /react-swipeable-views@0.14.0(react@18.2.0): + resolution: {integrity: sha512-wrTT6bi2nC3JbmyNAsPXffUXLn0DVT9SbbcFr36gKpbaCgEp7rX/OFxsu5hPc/NBsUhHyoSRGvwqJNNrWTwCww==} + engines: {node: '>=6.0.0'} + peerDependencies: + react: ^15.3.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@babel/runtime': 7.0.0 + prop-types: 15.8.1 + react: 18.2.0 + react-swipeable-views-core: 0.14.0 + react-swipeable-views-utils: 0.14.0(react@18.2.0) + warning: 4.0.3 + dev: false + /react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} peerDependencies: @@ -5509,6 +5589,10 @@ packages: '@babel/runtime': 7.21.5 dev: false + /regenerator-runtime@0.12.1: + resolution: {integrity: sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==} + dev: false + /regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} dev: false @@ -5669,6 +5753,10 @@ packages: upper-case-first: 2.0.2 dev: true + /shallow-equal@1.2.1: + resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==} + dev: false + /shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -6365,6 +6453,12 @@ packages: dependencies: makeerror: 1.0.12 + /warning@4.0.3: + resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + dependencies: + loose-envify: 1.4.0 + dev: false + /webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} diff --git a/frontend/appflowy_tauri/scripts/i18n/index.cjs b/frontend/appflowy_tauri/scripts/i18n/index.cjs index 0a95a0111083..21fef44e10ac 100644 --- a/frontend/appflowy_tauri/scripts/i18n/index.cjs +++ b/frontend/appflowy_tauri/scripts/i18n/index.cjs @@ -15,7 +15,7 @@ const languages = [ 'pt-BR', 'pt-PT', 'ru-RU', - 'sv', + 'sv-SE', 'tr-TR', 'zh-CN', 'zh-TW', diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.lock b/frontend/appflowy_tauri/src-tauri/Cargo.lock index c43f9d217cd3..40bdee05183b 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.lock +++ b/frontend/appflowy_tauri/src-tauri/Cargo.lock @@ -129,21 +129,27 @@ dependencies = [ "libc", ] -[[package]] -name = "ansi_term" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" -dependencies = [ - "winapi", -] - [[package]] name = "anyhow" version = "1.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" +[[package]] +name = "app-error" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=fe977fc8285addd5386e940738cdffbbda9eb44e#fe977fc8285addd5386e940738cdffbbda9eb44e" +dependencies = [ + "anyhow", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "url", + "uuid", +] + [[package]] name = "appflowy_tauri" version = "0.0.0" @@ -189,7 +195,7 @@ checksum = "7150fb5d9cc4eb0184af43ce75a89620dc3747d3c816e8b0ba200682d0155c05" dependencies = [ "async-convert", "backoff", - "base64 0.21.2", + "base64 0.21.5", "derive_builder", "futures", "rand 0.8.5", @@ -228,9 +234,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.73" +version = "0.1.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc00ceb34980c03614e35a3a4e218276a0a824e911d07651cd0d858a51e8c0f0" +checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9" dependencies = [ "proc-macro2", "quote", @@ -359,9 +365,9 @@ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] name = "base64" -version = "0.21.2" +version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d" +checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" [[package]] name = "bincode" @@ -454,7 +460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4114279215a005bc675e386011e594e1d9b800918cea18fcadadcce864a2046b" dependencies = [ "borsh-derive", - "hashbrown 0.13.2", + "hashbrown 0.12.3", ] [[package]] @@ -762,9 +768,11 @@ dependencies = [ [[package]] name = "client-api" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c87d0f05e988a02e9272a42722b304289be320e4#c87d0f05e988a02e9272a42722b304289be320e4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=fe977fc8285addd5386e940738cdffbbda9eb44e#fe977fc8285addd5386e940738cdffbbda9eb44e" dependencies = [ "anyhow", + "app-error", + "async-trait", "bytes", "collab", "collab-entity", @@ -777,9 +785,10 @@ dependencies = [ "mime", "mime_guess", "parking_lot", + "prost", "realtime-entity", "reqwest", - "scraper", + "scraper 0.17.1", "serde", "serde_json", "serde_repr", @@ -854,10 +863,11 @@ dependencies = [ [[package]] name = "collab" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ "anyhow", "async-trait", + "bincode", "bytes", "lib0", "parking_lot", @@ -873,11 +883,11 @@ dependencies = [ [[package]] name = "collab-database" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ "anyhow", "async-trait", - "base64 0.21.2", + "base64 0.21.5", "chrono", "collab", "collab-derive", @@ -903,7 +913,7 @@ dependencies = [ [[package]] name = "collab-derive" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ "proc-macro2", "quote", @@ -915,7 +925,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ "anyhow", "collab", @@ -935,7 +945,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ "anyhow", "bytes", @@ -949,7 +959,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ "anyhow", "chrono", @@ -991,8 +1001,9 @@ dependencies = [ [[package]] name = "collab-persistence" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ + "anyhow", "async-trait", "bincode", "chrono", @@ -1012,7 +1023,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ "anyhow", "async-trait", @@ -1039,7 +1050,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ "anyhow", "collab", @@ -1438,9 +1449,10 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c87d0f05e988a02e9272a42722b304289be320e4#c87d0f05e988a02e9272a42722b304289be320e4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=fe977fc8285addd5386e940738cdffbbda9eb44e#fe977fc8285addd5386e940738cdffbbda9eb44e" dependencies = [ "anyhow", + "app-error", "chrono", "collab-entity", "serde", @@ -1839,6 +1851,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.0.26" @@ -1915,6 +1933,7 @@ name = "flowy-core" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.21.5", "bytes", "client-api", "collab", @@ -1939,6 +1958,7 @@ dependencies = [ "flowy-task", "flowy-user", "flowy-user-deps", + "futures", "futures-core", "lib-dispatch", "lib-infra", @@ -1951,6 +1971,7 @@ dependencies = [ "tokio-stream", "tracing", "uuid", + "walkdir", ] [[package]] @@ -1991,6 +2012,7 @@ dependencies = [ "lazy_static", "lib-dispatch", "lib-infra", + "lru", "nanoid", "parking_lot", "protobuf", @@ -2069,9 +2091,11 @@ dependencies = [ "indexmap 1.9.3", "lib-dispatch", "lib-infra", + "lru", "nanoid", "parking_lot", "protobuf", + "scraper 0.18.1", "serde", "serde_json", "strum_macros 0.21.1", @@ -2079,6 +2103,7 @@ dependencies = [ "tokio-stream", "tracing", "uuid", + "validator", ] [[package]] @@ -2087,7 +2112,7 @@ version = "0.1.0" dependencies = [ "aes-gcm", "anyhow", - "base64 0.21.2", + "base64 0.21.5", "hmac", "pbkdf2", "rand 0.8.5", @@ -2115,6 +2140,7 @@ dependencies = [ "serde_json", "serde_repr", "thiserror", + "tokio", "tokio-postgres", "url", "validator", @@ -2200,6 +2226,7 @@ dependencies = [ "hex", "hyper", "lazy_static", + "lib-dispatch", "lib-infra", "mime_guess", "parking_lot", @@ -2276,7 +2303,7 @@ name = "flowy-user" version = "0.1.0" dependencies = [ "anyhow", - "base64 0.21.2", + "base64 0.21.5", "bytes", "chrono", "collab", @@ -2301,7 +2328,6 @@ dependencies = [ "lazy_static", "lib-dispatch", "lib-infra", - "log", "once_cell", "parking_lot", "protobuf", @@ -2382,9 +2408,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40" +checksum = "da0290714b38af9b4a7b094b8a37086d1b4e61f2df9122c3cad2577669145335" dependencies = [ "futures-channel", "futures-core", @@ -2397,9 +2423,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" +checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" dependencies = [ "futures-core", "futures-sink", @@ -2407,15 +2433,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" +checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" [[package]] name = "futures-executor" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" +checksum = "0f4fb8693db0cf099eadcca0efe2a5a22e4550f98ed16aba6c48700da29597bc" dependencies = [ "futures-core", "futures-task", @@ -2435,15 +2461,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" +checksum = "8bf34a163b5c4c52d0478a4d757da8fb65cabef42ba90515efee0f6f9fa45aaa" [[package]] name = "futures-macro" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" +checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" dependencies = [ "proc-macro2", "quote", @@ -2452,15 +2478,15 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" +checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" [[package]] name = "futures-task" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" +checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2" [[package]] name = "futures-timer" @@ -2470,9 +2496,9 @@ checksum = "e64b03909df88034c26dc1547e8970b91f98bdb65165d6a4e9110d94263dbb2c" [[package]] name = "futures-util" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" +checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" dependencies = [ "futures-channel", "futures-core", @@ -2782,7 +2808,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c87d0f05e988a02e9272a42722b304289be320e4#c87d0f05e988a02e9272a42722b304289be320e4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=fe977fc8285addd5386e940738cdffbbda9eb44e#fe977fc8285addd5386e940738cdffbbda9eb44e" dependencies = [ "anyhow", "futures-util", @@ -2798,9 +2824,10 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c87d0f05e988a02e9272a42722b304289be320e4#c87d0f05e988a02e9272a42722b304289be320e4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=fe977fc8285addd5386e940738cdffbbda9eb44e#fe977fc8285addd5386e940738cdffbbda9eb44e" dependencies = [ "anyhow", + "app-error", "jsonwebtoken", "lazy_static", "reqwest", @@ -2891,15 +2918,6 @@ dependencies = [ "ahash 0.7.6", ] -[[package]] -name = "hashbrown" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" -dependencies = [ - "ahash 0.8.3", -] - [[package]] name = "hashbrown" version = "0.14.0" @@ -3233,7 +3251,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c87d0f05e988a02e9272a42722b304289be320e4#c87d0f05e988a02e9272a42722b304289be320e4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=fe977fc8285addd5386e940738cdffbbda9eb44e#fe977fc8285addd5386e940738cdffbbda9eb44e" dependencies = [ "anyhow", "reqwest", @@ -3385,7 +3403,7 @@ version = "8.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" dependencies = [ - "base64 0.21.2", + "base64 0.21.5", "pem", "ring", "serde", @@ -3442,8 +3460,8 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "log", "nanoid", + "parking_lot", "pin-project", "protobuf", "serde", @@ -3476,15 +3494,13 @@ version = "0.1.0" dependencies = [ "chrono", "lazy_static", - "log", "serde", "serde_json", "tracing", "tracing-appender", "tracing-bunyan-formatter", "tracing-core", - "tracing-log", - "tracing-subscriber 0.2.25", + "tracing-subscriber", ] [[package]] @@ -3500,9 +3516,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.147" +version = "0.2.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" +checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" [[package]] name = "libloading" @@ -3606,16 +3622,16 @@ dependencies = [ "serde", "serde_json", "tracing", - "tracing-subscriber 0.3.17", + "tracing-subscriber", ] [[package]] name = "lru" -version = "0.10.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03f1160296536f10c833a82dca22267d5486734230d47bf00bf435885814ba1e" +checksum = "1efa59af2ddfad1854ae27d75009d538d0998b4b2fd47083e743ac1a10e46c60" dependencies = [ - "hashbrown 0.13.2", + "hashbrown 0.14.0", ] [[package]] @@ -3661,15 +3677,6 @@ dependencies = [ "tendril", ] -[[package]] -name = "matchers" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f099785f7595cc4b4553a174ce30dd7589ef93391ff414dbb67f62392b9e0ce1" -dependencies = [ - "regex-automata", -] - [[package]] name = "matchers" version = "0.1.0" @@ -3799,15 +3806,21 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" dependencies = [ "libc", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.48.0", ] +[[package]] +name = "multimap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" + [[package]] name = "nanoid" version = "0.4.0" @@ -4281,6 +4294,16 @@ dependencies = [ "sha2", ] +[[package]] +name = "petgraph" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d3afd2628e69da2be385eb6f2fd57c8ac7977ceeff6dc166ff1657b0e386a9" +dependencies = [ + "fixedbitset", + "indexmap 2.0.0", +] + [[package]] name = "phf" version = "0.8.0" @@ -4464,9 +4487,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.9" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" [[package]] name = "pin-utils" @@ -4486,7 +4509,7 @@ version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bd9647b268a3d3e14ff09c23201133a62589c658db02bb7388c7246aafe0590" dependencies = [ - "base64 0.21.2", + "base64 0.21.5", "indexmap 1.9.3", "line-wrap", "quick-xml 0.28.2", @@ -4525,7 +4548,7 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b7fa9f396f51dffd61546fd8573ee20592287996568e6175ceb0f8699ad75d" dependencies = [ - "base64 0.21.2", + "base64 0.21.5", "byteorder", "bytes", "fallible-iterator", @@ -4637,6 +4660,60 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fdd22f3b9c31b53c060df4a0613a1c7f062d4115a2b984dd15b1858f7e340d" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bdf592881d821b83d471f8af290226c8d51402259e9bb5be7f9f8bdebbb11ac" +dependencies = [ + "bytes", + "heck 0.4.1", + "itertools 0.11.0", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.29", + "tempfile", + "which", +] + +[[package]] +name = "prost-derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "265baba7fabd416cf5078179f7d2cbeca4ce7a9041111900675ea7c4cb8a4c32" +dependencies = [ + "anyhow", + "itertools 0.11.0", + "proc-macro2", + "quote", + "syn 2.0.29", +] + +[[package]] +name = "prost-types" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e081b29f63d83a4bc75cfc9f3fe424f9156cf92d8a4f0c9407cce9a1b67327cf" +dependencies = [ + "prost", +] + [[package]] name = "protobuf" version = "2.28.0" @@ -4917,13 +4994,20 @@ dependencies = [ [[package]] name = "realtime-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c87d0f05e988a02e9272a42722b304289be320e4#c87d0f05e988a02e9272a42722b304289be320e4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=fe977fc8285addd5386e940738cdffbbda9eb44e#fe977fc8285addd5386e940738cdffbbda9eb44e" dependencies = [ + "anyhow", + "bincode", "bytes", "collab", "collab-entity", + "database-entity", + "prost", + "prost-build", + "protoc-bin-vendored", "serde", "serde_json", + "tokio-tungstenite", ] [[package]] @@ -5008,7 +5092,7 @@ version = "0.11.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e9ad3fe7488d7e34558a2033d45a0c90b72d97b4f80705666fea71472e2e6a1" dependencies = [ - "base64 0.21.2", + "base64 0.21.5", "bytes", "cookie", "cookie_store", @@ -5255,7 +5339,7 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d194b56d58803a43635bdc398cd17e383d6f71f9182b9a192c127ca42494a59b" dependencies = [ - "base64 0.21.2", + "base64 0.21.5", ] [[package]] @@ -5369,6 +5453,22 @@ dependencies = [ "tendril", ] +[[package]] +name = "scraper" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585480e3719b311b78a573db1c9d9c4c1f8010c2dee4cc59c2efe58ea4dbc3e1" +dependencies = [ + "ahash 0.8.3", + "cssparser 0.31.2", + "ego-tree", + "getopts", + "html5ever 0.26.0", + "once_cell", + "selectors 0.25.0", + "tendril", +] + [[package]] name = "sct" version = "0.7.0" @@ -5489,9 +5589,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.107" +version = "1.0.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b420ce6e3d8bd882e9b243c6eed35dbc9a6110c9769e74b584e0d68d1f20c65" +checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" dependencies = [ "itoa 1.0.6", "ryu", @@ -5536,7 +5636,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f02d8aa6e3c385bf084924f660ce2a3a6bd333ba55b35e8590b321f35d88513" dependencies = [ - "base64 0.21.2", + "base64 0.21.5", "chrono", "hex", "indexmap 1.9.3", @@ -5639,9 +5739,10 @@ dependencies = [ [[package]] name = "shared_entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c87d0f05e988a02e9272a42722b304289be320e4#c87d0f05e988a02e9272a42722b304289be320e4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=fe977fc8285addd5386e940738cdffbbda9eb44e#fe977fc8285addd5386e940738cdffbbda9eb44e" dependencies = [ "anyhow", + "app-error", "collab-entity", "database-entity", "gotrue-entity", @@ -5769,9 +5870,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.3" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2538b18701741680e0322a2302176d3253a35388e2e62f172f64f4f16605f877" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" dependencies = [ "libc", "windows-sys 0.48.0", @@ -6203,7 +6304,7 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54ad2d49fdeab4a08717f5b49a163bdc72efc3b1950b6758245fcde79b645e1a" dependencies = [ - "base64 0.21.2", + "base64 0.21.5", "brotli", "ico", "json-patch", @@ -6465,11 +6566,11 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.28.2" +version = "1.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94d7b1cfd2aa4011f2de74c2c4c63665e27a71006b0a192dcd2710272e73dfa2" +checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9" dependencies = [ - "autocfg", + "backtrace", "bytes", "libc", "mio", @@ -6477,16 +6578,16 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.4.9", + "socket2 0.5.5", "tokio-macros", "windows-sys 0.48.0", ] [[package]] name = "tokio-macros" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", @@ -6522,7 +6623,7 @@ dependencies = [ "pin-project-lite", "postgres-protocol", "postgres-types", - "socket2 0.5.3", + "socket2 0.5.5", "tokio", "tokio-util", ] @@ -6639,11 +6740,10 @@ checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" [[package]] name = "tracing" -version = "0.1.37" +version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ - "cfg-if", "log", "pin-project-lite", "tracing-attributes", @@ -6652,20 +6752,20 @@ dependencies = [ [[package]] name = "tracing-appender" -version = "0.1.2" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9965507e507f12c8901432a33e31131222abac31edd90cabbcf85cf544b7127a" +checksum = "09d48f71a791638519505cefafe162606f706c25592e4bde4d97600c0195312e" dependencies = [ - "chrono", "crossbeam-channel", - "tracing-subscriber 0.2.25", + "time", + "tracing-subscriber", ] [[package]] name = "tracing-attributes" -version = "0.1.26" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", @@ -6674,26 +6774,27 @@ dependencies = [ [[package]] name = "tracing-bunyan-formatter" -version = "0.2.6" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c408910c9b7eabc0215fe2b4a89f8ec95581a91cea1f7619f7c78caf14cbc2a1" +checksum = "b5c266b9ac83dedf0e0385ad78514949e6d89491269e7065bee51d2bb8ec7373" dependencies = [ - "chrono", + "ahash 0.8.3", "gethostname", "log", "serde", "serde_json", + "time", "tracing", "tracing-core", "tracing-log", - "tracing-subscriber 0.2.25", + "tracing-subscriber", ] [[package]] name = "tracing-core" -version = "0.1.31" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", "valuable", @@ -6720,44 +6821,25 @@ dependencies = [ "tracing-core", ] -[[package]] -name = "tracing-subscriber" -version = "0.2.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0d2eaa99c3c2e41547cfa109e910a68ea03823cccad4a0525dcbc9b01e8c71" -dependencies = [ - "ansi_term", - "chrono", - "lazy_static", - "matchers 0.0.1", - "regex", - "serde", - "serde_json", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", - "tracing-serde", -] - [[package]] name = "tracing-subscriber" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77" dependencies = [ - "matchers 0.1.0", + "matchers", "nu-ansi-term", "once_cell", "regex", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -6950,9 +7032,9 @@ checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] name = "uuid" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" +checksum = "88ad59a7560b41a70d191093a945f0b87bc1deeda46fb237479708a1d6b6cdfc" dependencies = [ "getrandom 0.2.10", "serde", @@ -7053,9 +7135,9 @@ dependencies = [ [[package]] name = "walkdir" -version = "2.3.3" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36df944cda56c7d8d8b7496af378e6b16de9284591917d307c9b4d313c44e698" +checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" dependencies = [ "same-file", "winapi-util", diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index 3ee84542e581..079e2cc4db2d 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -13,13 +13,31 @@ rust-version = "1.57" [build-dependencies] tauri-build = { version = "1.2", features = [] } +[workspace.dependencies] +anyhow = "1.0.75" +tracing = "0.1.40" +bytes = "1.5.0" +serde = "1.0.108" +serde_json = "1.0.108" +protobuf = { version = "2.28.0" } +diesel = { version = "1.4.8", features = ["sqlite", "chrono"] } +uuid = { version = "1.5.0", features = ["serde", "v4"] } +serde_repr = "0.1" +parking_lot = "0.12" +futures = "0.3.29" +tokio = "1.34.0" +tokio-stream = "0.1.14" +async-trait = "0.1.74" +chrono = { version = "0.4.31", default-features = false, features = ["clock"] } +lru = "0.12.0" + [dependencies] -serde_json = "1.0" -serde = { version = "1.0", features = ["derive"] } +serde_json.workspace = true +serde.workspace = true tauri = { version = "1.2", features = ["fs-all", "shell-open"] } tauri-utils = "1.2" -bytes = { version = "1.5" } -tracing = { version = "0.1", features = ["log"] } +bytes.workspace = true +tracing.workspace = true lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = ["use_serde"] } flowy-core = { path = "../../rust-lib/flowy-core", features = ["rev-sqlite", "ts"] } flowy-notification = { path = "../../rust-lib/flowy-notification", features = ["ts"] } @@ -38,7 +56,7 @@ custom-protocol = ["tauri/custom-protocol"] # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "c87d0f05e988a02e9272a42722b304289be320e4" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "fe977fc8285addd5386e940738cdffbbda9eb44e" } # Please use the following script to update collab. # Working directory: frontend # @@ -48,14 +66,14 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "c87 # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "ff10abd5" } -collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "ff10abd5" } -collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "ff10abd5" } -collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "ff10abd5" } -collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "ff10abd5" } -collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "ff10abd5" } -collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "ff10abd5" } -collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "ff10abd5" } +collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b8097fa891bdbb5826d6e480460c50ab66d26881" } +collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b8097fa891bdbb5826d6e480460c50ab66d26881" } +collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b8097fa891bdbb5826d6e480460c50ab66d26881" } +collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b8097fa891bdbb5826d6e480460c50ab66d26881" } +collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b8097fa891bdbb5826d6e480460c50ab66d26881" } +collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b8097fa891bdbb5826d6e480460c50ab66d26881" } +collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b8097fa891bdbb5826d6e480460c50ab66d26881" } +collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b8097fa891bdbb5826d6e480460c50ab66d26881" } diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/board.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/board.svg new file mode 100644 index 000000000000..0bb0e3fabe56 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/board.svg @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/close.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/close.svg new file mode 100644 index 000000000000..b519b419c0bc --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/document.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/document.svg new file mode 100644 index 000000000000..b00e1cfb38c9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/document.svg @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/grid.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/grid.svg new file mode 100644 index 000000000000..c397af813011 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/grid.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/up.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/up.svg new file mode 100644 index 000000000000..bd8f3067d317 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/up.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDragDropContext.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDragDropContext.tsx index 2b0a307a37c4..0d7076b9bc4f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDragDropContext.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDragDropContext.tsx @@ -66,7 +66,7 @@ function BlockDragDropContext({ children }: { children: React.ReactNode }) { }; const onDragEnd = () => { - dispatch(onDragEndThunk()); + void dispatch(onDragEndThunk()); unlisten(); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/index.tsx index eaf0530c21aa..9ae0ebc545d5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/index.tsx @@ -18,7 +18,7 @@ function BlockDraggable( } & HTMLAttributes, ref: React.Ref ) { - const { onDragStart, beforeDropping, afterDropping, childDropping, isDragging } = useDraggableState(id, type); + const { onDragStart, beforeDropping, afterDropping, childDropping } = useDraggableState(id, type); const commonCls = 'pointer-events-none absolute z-10 w-[100%] bg-fill-hover transition-all duration-200'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/Database.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/Database.hooks.ts index 50de845116f2..9b0955793e55 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/Database.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/Database.hooks.ts @@ -1,11 +1,6 @@ -import { useAppDispatch, useAppSelector } from '../../stores/store'; -import { useEffect, useState } from 'react'; -import { databaseActions, IDatabase } from '../../stores/reducers/database/slice'; -import { nanoid } from 'nanoid'; -import { FieldType } from '../../../services/backend'; +import { useAppSelector } from '$app/stores/store'; export const useDatabase = () => { - const dispatch = useAppDispatch(); const database = useAppSelector((state) => state.database); const newField = () => { @@ -22,7 +17,7 @@ export const useDatabase = () => { console.log('depreciated'); }; - const renameField = (fieldId: string, newTitle: string) => { + const renameField = (_fieldId: string, _newTitle: string) => { /* const field = database.fields[fieldId]; field.title = newTitle; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/DatabaseFilter/DatabaseFilterItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/DatabaseFilter/DatabaseFilterItem.tsx index 87c766c8146a..b0b7d4132263 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/DatabaseFilter/DatabaseFilterItem.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/DatabaseFilter/DatabaseFilterItem.tsx @@ -75,6 +75,7 @@ export const DatabaseFilterItem = ({ value: currentValue, }); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentFieldId, currentFieldType, currentOperator, currentValue, textInputActive]); // 1. not all field types support filtering diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/DatabaseSort/DatabaseSortItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/DatabaseSort/DatabaseSortItem.tsx index bc308e574ce7..5e096dce6ea7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/DatabaseSort/DatabaseSortItem.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/DatabaseSort/DatabaseSortItem.tsx @@ -54,6 +54,7 @@ export const DatabaseSortItem = ({ fieldType: fields[currentFieldId].fieldType, }); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentFieldId, currentOrder]); const onSelectFieldClick = (id: string) => { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/DatabaseSort/DatabaseSortPopup.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/DatabaseSort/DatabaseSortPopup.tsx index fe2c89772a33..98247e3a1a7e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/DatabaseSort/DatabaseSortPopup.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/DatabaseSort/DatabaseSortPopup.tsx @@ -1,5 +1,5 @@ import { t } from 'i18next'; -import { MouseEventHandler, useMemo, useRef, useState } from 'react'; +import { MouseEventHandler, useMemo, useState } from 'react'; import { useAppSelector } from '$app/stores/store'; import { IDatabaseSort } from '$app_reducers/database/slice'; import { DatabaseSortItem } from '$app/components/_shared/DatabaseSort/DatabaseSortItem'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/CheckList/NewCheckListOption.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/CheckList/NewCheckListOption.tsx index 1b9a24c1166b..940929660a09 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/CheckList/NewCheckListOption.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/CheckList/NewCheckListOption.tsx @@ -19,6 +19,7 @@ export const NewCheckListOption = ({ const updateNewOption = (value: string) => { const newOptionsCopy = [...newOptions]; + newOptionsCopy[index] = value; setNewOptions(newOptionsCopy); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/Date/DateFormatPopup.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/Date/DateFormatPopup.tsx index 0ba94e577722..58e68fd28151 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/Date/DateFormatPopup.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/Date/DateFormatPopup.tsx @@ -29,6 +29,7 @@ export const DateFormatPopup = ({ useEffect(() => { setDateType(databaseStore.fields[cellIdentifier.fieldId]?.fieldOptions as IDateType); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [databaseStore]); const changeFormat = async (format: DateFormatPB) => { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/Date/DatePickerPopup.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/Date/DatePickerPopup.tsx index e9eb02af8ef8..8d5de41e4135 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/Date/DatePickerPopup.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/Date/DatePickerPopup.tsx @@ -30,6 +30,7 @@ export const DatePickerPopup = ({ useEffect(() => { const date_pb = data as DateCellDataPB | undefined; + if (!date_pb || !date_pb?.date.length) return; setSelectedDate(dayjs(date_pb.date).toDate()); @@ -39,6 +40,7 @@ export const DatePickerPopup = ({ if (v instanceof Date) { setSelectedDate(v); const date = new CalendarData(dayjs(v).add(dayjs().utcOffset(), 'minutes').toDate(), false); + await cellController?.saveCellData(date); } }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/Date/DateTimeFormat.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/Date/DateTimeFormat.hooks.ts index e024881350a0..25784c2efc1b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/Date/DateTimeFormat.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/Date/DateTimeFormat.hooks.ts @@ -8,11 +8,14 @@ import { FieldController } from '$app/stores/effects/database/field/field_contro export const useDateTimeFormat = (cellIdentifier: CellIdentifier, fieldController: FieldController) => { const changeFormat = async (change: (option: DateTypeOptionPB) => void) => { const fieldInfo = fieldController.getField(cellIdentifier.fieldId); + if (!fieldInfo) return; const typeOptionController = new TypeOptionController(cellIdentifier.viewId, Some(fieldInfo), FieldType.DateTime); + await typeOptionController.initialize(); const dateTypeOptionContext = makeDateTypeOptionContext(typeOptionController); const typeOption = await dateTypeOptionContext.getTypeOption().then((a) => a.unwrap()); + change(typeOption); await dateTypeOptionContext.setTypeOption(typeOption); }; @@ -20,11 +23,13 @@ export const useDateTimeFormat = (cellIdentifier: CellIdentifier, fieldControlle const changeDateFormat = async (format: DateFormatPB) => { await changeFormat((option) => (option.date_format = format)); }; + const changeTimeFormat = async (format: TimeFormatPB) => { await changeFormat((option) => (option.time_format = format)); }; - const includeTime = async (include: boolean) => { - await changeFormat((option) => { + + const includeTime = async (_include: boolean) => { + await changeFormat((_option) => { // option.include_time = include; }); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/Date/DateTypeOptions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/Date/DateTypeOptions.tsx index 0b3f9415d216..c5e813b8b518 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/Date/DateTypeOptions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/Date/DateTypeOptions.tsx @@ -1,8 +1,6 @@ import { DateFormatPopup } from '$app/components/_shared/EditRow/Date/DateFormatPopup'; import { TimeFormatPopup } from '$app/components/_shared/EditRow/Date/TimeFormatPopup'; import { MoreSvg } from '$app/components/_shared/svg/MoreSvg'; -import { EditorCheckSvg } from '$app/components/_shared/svg/EditorCheckSvg'; -import { EditorUncheckSvg } from '$app/components/_shared/svg/EditorUncheckSvg'; import { MouseEventHandler, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { IDateType } from '$app_reducers/database/slice'; @@ -27,14 +25,16 @@ export const DateTypeOptions = ({ const [showTimeFormatPopup, setShowTimeFormatPopup] = useState(false); const [timeFormatTop, setTimeFormatTop] = useState(0); const [timeFormatLeft, setTimeFormatLeft] = useState(0); - + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [dateType, setDateType] = useState(); const databaseStore = useAppSelector((state) => state.database); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { includeTime } = useDateTimeFormat(cellIdentifier, fieldController); useEffect(() => { setDateType(databaseStore.fields[cellIdentifier.fieldId]?.fieldOptions as IDateType); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [databaseStore]); const onDateFormatClick = (_left: number, _top: number) => { @@ -87,7 +87,7 @@ export const DateTypeOptions = ({ return (
-
+
{openMenu && ( - + )} - +
); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridField/GridFieldMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridField/GridFieldMenu.tsx index 77ef340452f2..e531d7a8e015 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridField/GridFieldMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridField/GridFieldMenu.tsx @@ -1,27 +1,20 @@ import { Divider, Menu, MenuItem, MenuProps, OutlinedInput } from '@mui/material'; import { ChangeEventHandler, FC, useCallback, useState } from 'react'; import { ReactComponent as MoreSvg } from '$app/assets/more.svg'; -import { Database } from '$app/interfaces/database'; -import * as service from '$app/components/database/database_bd_svc'; -import { useViewId } from '../../database.hooks'; +import { useViewId } from '$app/hooks'; +import { Field, fieldService } from '../../application'; import { FieldTypeSvg } from './FieldTypeSvg'; import { FieldTypeText } from './FieldTypeText'; import { GridFieldMenuActions } from './GridFieldMenuActions'; - export interface GridFieldMenuProps { - field: Database.Field; + field: Field; anchorEl: MenuProps['anchorEl']; open: boolean; - onClose: MenuProps['onClose']; + onClose: () => void; } -export const GridFieldMenu: FC = ({ - field, - anchorEl, - open, - onClose, -}) => { +export const GridFieldMenu: FC = ({ field, anchorEl, open, onClose }) => { const viewId = useViewId(); const [inputtingName, setInputtingName] = useState(field.name); @@ -32,7 +25,7 @@ export const GridFieldMenu: FC = ({ const handleBlur = useCallback(async () => { if (inputtingName !== field.name) { try { - await service.updateField(viewId, field.id, { + await fieldService.updateField(viewId, field.id, { name: inputtingName, }); } catch (e) { @@ -44,8 +37,8 @@ export const GridFieldMenu: FC = ({ const fieldNameInput = ( = ({ const fieldTypeSelect = ( - - - {FieldTypeText(field.type)} - - + + {FieldTypeText(field.type)} + ); + const isPrimary = field.isPrimary; + return ( - - {fieldNameInput} - {fieldTypeSelect} - - - + <> + + {fieldNameInput} + {!isPrimary && ( + <> + {fieldTypeSelect} + + + )} + + onClose()} fieldId={field.id} /> + + ); -}; \ No newline at end of file +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridField/GridFieldMenuActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridField/GridFieldMenuActions.tsx index 26a4f27c15be..85c29b668b90 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridField/GridFieldMenuActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridField/GridFieldMenuActions.tsx @@ -6,6 +6,11 @@ import { ReactComponent as CopySvg } from '$app/assets/copy.svg'; import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg'; import { ReactComponent as LeftSvg } from '$app/assets/left.svg'; import { ReactComponent as RightSvg } from '$app/assets/right.svg'; +import { fieldService } from '$app/components/database/application'; +import { FieldVisibility } from '@/services/backend'; +import { useViewId } from '$app/hooks'; +import ConfirmDialog from '$app/components/_shared/app-dialog/ConfirmDialog'; +import { useState } from 'react'; enum FieldAction { Hide = 'hide', @@ -25,26 +30,79 @@ const FieldActionSvgMap = { const TwoColumnActions: FieldAction[][] = [ [FieldAction.Hide, FieldAction.Duplicate, FieldAction.Delete], - [FieldAction.InsertLeft, FieldAction.InsertRight], + // [FieldAction.InsertLeft, FieldAction.InsertRight], ]; -export const GridFieldMenuActions = () => { +// prevent default actions for primary fields +const primaryPreventDefaultActions = [FieldAction.Delete, FieldAction.Duplicate]; + +interface GridFieldMenuActionsProps { + fieldId: string; + isPrimary?: boolean; + onMenuItemClick?: (action: FieldAction) => void; +} + +export const GridFieldMenuActions = ({ fieldId, onMenuItemClick, isPrimary }: GridFieldMenuActionsProps) => { + const viewId = useViewId(); + const [openConfirm, setOpenConfirm] = useState(false); + + const handleOpenConfirm = () => { + setOpenConfirm(true); + }; + + const handleMenuItemClick = async (action: FieldAction) => { + const preventDefault = isPrimary && primaryPreventDefaultActions.includes(action); + + if (preventDefault) { + return; + } + + switch (action) { + case FieldAction.Hide: + await fieldService.updateFieldSetting(viewId, fieldId, { + visibility: FieldVisibility.AlwaysHidden, + }); + break; + case FieldAction.Duplicate: + await fieldService.duplicateField(viewId, fieldId); + break; + case FieldAction.Delete: + handleOpenConfirm(); + return; + } + + onMenuItemClick?.(action); + }; + return ( - + {TwoColumnActions.map((column, index) => ( - {column.map(action => { + {column.map((action) => { const ActionSvg = FieldActionSvgMap[action]; + const disabled = isPrimary && primaryPreventDefaultActions.includes(action); return ( - - + handleMenuItemClick(action)} key={action} dense> + {t(`grid.field.${action}`)} ); })} ))} + { + await fieldService.deleteField(viewId, fieldId); + }} + onClose={() => { + setOpenConfirm(false); + onMenuItemClick?.(FieldAction.Delete); + }} + /> ); -}; \ No newline at end of file +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridField/GridResizer.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridField/GridResizer.tsx new file mode 100644 index 000000000000..6a834b6fab3f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridField/GridResizer.tsx @@ -0,0 +1,92 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Field, fieldService } from '$app/components/database/application'; +import { useViewId } from '$app/hooks'; + +interface GridResizerProps { + field: Field; + onWidthChange?: (width: number) => void; +} + +const minWidth = 100; + +function GridResizer({ field, onWidthChange }: GridResizerProps) { + const viewId = useViewId(); + const fieldId = field.id; + const width = field.width || 0; + const [isResizing, setIsResizing] = useState(false); + const [newWidth, setNewWidth] = useState(width); + const [hover, setHover] = useState(false); + const startX = useRef(0); + + const onResize = useCallback( + (e: MouseEvent) => { + const diff = e.clientX - startX.current; + const newWidth = width + diff; + + if (newWidth < minWidth) { + return; + } + + setNewWidth(newWidth); + }, + [width] + ); + + useEffect(() => { + onWidthChange?.(newWidth); + }, [newWidth, onWidthChange]); + + useEffect(() => { + if (!isResizing && width !== newWidth) { + void fieldService.updateFieldSetting(viewId, fieldId, { + width: newWidth, + }); + } + }, [fieldId, isResizing, newWidth, viewId, width]); + + const onResizeEnd = useCallback(() => { + setIsResizing(false); + document.removeEventListener('mousemove', onResize); + document.removeEventListener('mouseup', onResizeEnd); + }, [onResize]); + + const onResizeStart = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + startX.current = e.clientX; + setIsResizing(true); + document.addEventListener('mousemove', onResize); + document.addEventListener('mouseup', onResizeEnd); + }, + [onResize, onResizeEnd] + ); + + return ( +
{ + e.stopPropagation(); + }} + onMouseEnter={() => { + setHover(true); + }} + onMouseLeave={() => { + setHover(false); + }} + style={{ + right: `-3px`, + }} + className={'absolute top-0 z-10 h-full cursor-col-resize'} + > +
+
+ ); +} + +export default GridResizer; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCalculateRow.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCalculateRow.tsx index 871958e74e89..cd4c5a53f905 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCalculateRow.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCalculateRow.tsx @@ -1,3 +1,17 @@ -export const GridCalculateRow = () => { - return null; -}; +import React from 'react'; +import { useDatabaseVisibilityFields } from '$app/components/database'; +import GridCalculate from '$app/components/database/grid/GridCalculate/GridCalculate'; + +function GridCalculateRow() { + const fields = useDatabaseVisibilityFields(); + + return ( +
+ {fields.map((field, index) => { + return ; + })} +
+ ); +} + +export default GridCalculateRow; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow.tsx deleted file mode 100644 index 037d38074ea6..000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { Virtualizer } from '@tanstack/react-virtual'; -import { IconButton, Tooltip } from '@mui/material'; -import { t } from 'i18next'; -import { DragEventHandler, FC, useCallback, useMemo, useState } from 'react'; -import { Database } from '$app/interfaces/database'; -import { ReactComponent as DragSvg } from '$app/assets/drag.svg'; -import { throttle } from '$app/utils/tool'; -import { useDatabase, useViewId } from '../../database.hooks'; -import * as service from '../../database_bd_svc'; -import { DragItem, DragType, DropPosition, VirtualizedList, useDraggable, useDroppable, ScrollDirection } from '../../_shared'; -import { GridCell } from '../GridCell'; -import { GridCellRowActions } from './GridCellRowActions'; - -export interface GridCellRowProps { - row: Database.Row; - virtualizer: Virtualizer; -} - -export const GridCellRow: FC = ({ - row, - virtualizer, -}) => { - const viewId = useViewId(); - const { fields } = useDatabase(); - - const [ hover, setHover ] = useState(false); - const [ openTooltip, setOpenTooltip ] = useState(false); - const [ dropPosition, setDropPosition ] = useState(DropPosition.Before); - - const handleMouseEnter = useCallback(() => { - setHover(true); - }, []); - - const handleMouseLeave = useCallback(() => { - setHover(false); - }, []); - - const handleTooltipOpen = useCallback(() => { - setOpenTooltip(true); - }, []); - - const handleTooltipClose = useCallback(() => { - setOpenTooltip(false); - }, []); - - const dragData = useMemo(() => ({ - row, - }), [row]); - - const { - isDragging, - attributes, - listeners, - setPreviewRef, - previewRef, - } = useDraggable({ - type: DragType.Row, - data: dragData, - scrollOnEdge: { - direction: ScrollDirection.Vertical, - }, - }); - - const onDragOver = useMemo(() => { - return throttle((event) => { - const element = previewRef.current; - - if (!element) { - return; - } - - const { top, bottom } = element.getBoundingClientRect(); - const middle = (top + bottom) / 2; - - setDropPosition(event.clientY < middle ? DropPosition.Before : DropPosition.After); - }, 20); - }, [previewRef]); - - const onDrop = useCallback(({ data }: DragItem) => { - void service.moveRow(viewId, (data.row as Database.Row).id, row.id); - }, [viewId, row.id]); - - const { - isOver, - listeners: dropListeners, - } = useDroppable({ - accept: DragType.Row, - disabled: isDragging, - onDragOver, - onDrop, - }); - - return ( -
- - - - - - - -
- ( - - )} - /> -
- {isOver &&
} -
-
- ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRow.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRow.hooks.ts new file mode 100644 index 000000000000..0b564e703d1c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRow.hooks.ts @@ -0,0 +1,78 @@ +import { useGridUIStateDispatcher, useGridUIStateSelector } from '$app/components/database/proxy/grid/ui_state/actions'; +import { CSSProperties, useCallback, useEffect, useMemo, useState } from 'react'; + +export function useGridRowActionsDisplay(rowId: string, ref: React.RefObject) { + const { hoverRowId, isActivated } = useGridUIStateSelector(); + const hover = useMemo(() => { + return isActivated && hoverRowId === rowId; + }, [hoverRowId, rowId, isActivated]); + + const { setRowHover } = useGridUIStateDispatcher(); + const [actionsStyle, setActionsStyle] = useState(); + + const onMouseEnter = useCallback(() => { + setRowHover(rowId); + }, [setRowHover, rowId]); + + const onMouseLeave = useCallback(() => { + if (hover) { + setRowHover(null); + } + }, [setRowHover, hover]); + + useEffect(() => { + // Next frame to avoid layout thrashing + requestAnimationFrame(() => { + const element = ref.current; + + if (!hover || !element) { + setActionsStyle(undefined); + return; + } + + const rect = element.getBoundingClientRect(); + + setActionsStyle({ + position: 'absolute', + top: rect.top + 6, + left: rect.left - 50, + }); + }); + }, [ref, hover]); + + return { + actionsStyle, + onMouseEnter, + onMouseLeave, + hover, + }; +} + +export const useGridRowContextMenu = () => { + const [position, setPosition] = useState<{ left: number; top: number } | undefined>(); + + const isContextMenuOpen = useMemo(() => { + return !!position; + }, [position]); + + const closeContextMenu = useCallback(() => { + setPosition(undefined); + }, []); + + const openContextMenu = useCallback((event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + setPosition({ + left: event.clientX, + top: event.clientY, + }); + }, []); + + return { + isContextMenuOpen, + closeContextMenu, + openContextMenu, + position, + }; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRow.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRow.tsx new file mode 100644 index 000000000000..04da0dcf51af --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRow.tsx @@ -0,0 +1,153 @@ +import { Virtualizer } from '@tanstack/react-virtual'; +import { Portal } from '@mui/material'; +import { DragEventHandler, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { throttle } from '$app/utils/tool'; +import { useViewId } from '$app/hooks'; +import { useDatabaseVisibilityFields } from '../../../Database.hooks'; +import { rowService, RowMeta } from '../../../application'; +import { + DragItem, + DragType, + DropPosition, + VirtualizedList, + useDraggable, + useDroppable, + ScrollDirection, +} from '../../../_shared'; +import { GridCell } from '../../GridCell'; +import { GridCellRowActions } from './GridCellRowActions'; +import { + useGridRowActionsDisplay, + useGridRowContextMenu, +} from '$app/components/database/grid/GridRow/GridCellRow/GridCellRow.hooks'; +import GridCellRowContextMenu from '$app/components/database/grid/GridRow/GridCellRow/GridCellRowContextMenu'; +import { DEFAULT_FIELD_WIDTH } from '$app/components/database/grid/GridRow'; + +export interface GridCellRowProps { + rowMeta: RowMeta; + virtualizer: Virtualizer; + getPrevRowId: (id: string) => string | null; +} + +export const GridCellRow: FC = ({ rowMeta, virtualizer, getPrevRowId }) => { + const rowId = rowMeta.id; + const viewId = useViewId(); + const ref = useRef(null); + const { onMouseLeave, onMouseEnter, actionsStyle, hover } = useGridRowActionsDisplay(rowId, ref); + const { + isContextMenuOpen, + closeContextMenu, + openContextMenu, + position: contextMenuPosition, + } = useGridRowContextMenu(); + const fields = useDatabaseVisibilityFields(); + + const [dropPosition, setDropPosition] = useState(DropPosition.Before); + const dragData = useMemo( + () => ({ + rowMeta, + }), + [rowMeta] + ); + + const { + isDragging, + attributes: dragAttributes, + listeners: dragListeners, + setPreviewRef, + previewRef, + } = useDraggable({ + type: DragType.Row, + data: dragData, + scrollOnEdge: { + direction: ScrollDirection.Vertical, + }, + }); + + const onDragOver = useMemo(() => { + return throttle((event) => { + const element = previewRef.current; + + if (!element) { + return; + } + + const { top, bottom } = element.getBoundingClientRect(); + const middle = (top + bottom) / 2; + + setDropPosition(event.clientY < middle ? DropPosition.Before : DropPosition.After); + }, 20); + }, [previewRef]); + + const onDrop = useCallback( + ({ data }: DragItem) => { + void rowService.moveRow(viewId, (data.rowMeta as RowMeta).id, rowMeta.id); + }, + [viewId, rowMeta.id] + ); + + const { isOver, listeners: dropListeners } = useDroppable({ + accept: DragType.Row, + disabled: isDragging, + onDragOver, + onDrop, + }); + + useEffect(() => { + const element = ref.current; + + if (!element) { + return; + } + + element.addEventListener('contextmenu', openContextMenu); + return () => { + element.removeEventListener('contextmenu', openContextMenu); + }; + }, [openContextMenu]); + + return ( +
+
+ } + /> +
+ {isOver && ( +
+ )} +
+ + + {isContextMenuOpen && ( + + )} + +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRowActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRowActions.tsx new file mode 100644 index 000000000000..250c48339d8b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRowActions.tsx @@ -0,0 +1,85 @@ +import { IconButton, Tooltip } from '@mui/material'; +import { DragEventHandler, FC, HTMLAttributes, PropsWithChildren, useCallback, useRef, useState } from 'react'; +import { t } from 'i18next'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; +import { useViewId } from '$app/hooks'; +import { rowService } from '../../../application'; +import { ReactComponent as DragSvg } from '$app/assets/drag.svg'; +import GridCellRowMenu from '$app/components/database/grid/GridRow/GridCellRow/GridCellRowMenu'; +import Popover from '@mui/material/Popover'; + +export interface GridCellRowActionsProps extends HTMLAttributes { + rowId: string; + getPrevRowId: (id: string) => string | null; + dragProps: { + draggable?: boolean; + onDragStart?: DragEventHandler; + onDragEnd?: DragEventHandler; + }; + isHidden?: boolean; +} + +export const GridCellRowActions: FC> = ({ + isHidden, + rowId, + getPrevRowId, + className, + dragProps: { draggable, onDragStart, onDragEnd }, + ...props +}) => { + const viewId = useViewId(); + const ref = useRef(null); + const [openMenu, setOpenMenu] = useState(false); + const handleInsertRecordBelow = useCallback(() => { + void rowService.createRow(viewId, { + startRowId: rowId, + }); + }, [viewId, rowId]); + + const handleOpenMenu = () => { + setOpenMenu(true); + }; + + const handleCloseMenu = () => { + setOpenMenu(false); + }; + + if (isHidden) return null; + + return ( +
+ + + + + + + + + + + + handleCloseMenu} rowId={rowId} getPrevRowId={getPrevRowId} /> + +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRowContextMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRowContextMenu.tsx new file mode 100644 index 000000000000..f045991c7cda --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRowContextMenu.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import GridCellRowMenu from '$app/components/database/grid/GridRow/GridCellRow/GridCellRowMenu'; +import Popover from '@mui/material/Popover'; + +interface Props { + open: boolean; + onClose: () => void; + anchorPosition?: { + top: number; + left: number; + }; + rowId: string; + getPrevRowId: (id: string) => string | null; +} + +function GridCellRowContextMenu({ open, anchorPosition, onClose, rowId, getPrevRowId }: Props) { + return ( + + { + onClose(); + }} + /> + + ); +} + +export default GridCellRowContextMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRowMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRowMenu.tsx new file mode 100644 index 000000000000..55664ac48b7a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRowMenu.tsx @@ -0,0 +1,96 @@ +import React, { useCallback } from 'react'; +import { MenuList, MenuItem, Icon } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as UpSvg } from '$app/assets/up.svg'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; +import { ReactComponent as DelSvg } from '$app/assets/delete.svg'; +import { ReactComponent as CopySvg } from '$app/assets/copy.svg'; +import { rowService } from '$app/components/database/application'; +import { useViewId } from '@/appflowy_app/hooks/ViewId.hooks'; + +interface Props { + rowId: string; + getPrevRowId: (id: string) => string | null; + onClickItem: (label: string) => void; +} + +interface Option { + label: string; + icon: JSX.Element; + onClick: () => void; + divider?: boolean; +} + +function GridCellRowMenu({ rowId, getPrevRowId, onClickItem }: Props) { + const viewId = useViewId(); + + const { t } = useTranslation(); + + const handleInsertRecordBelow = useCallback(() => { + void rowService.createRow(viewId, { + startRowId: rowId, + }); + }, [viewId, rowId]); + + const handleInsertRecordAbove = useCallback(() => { + const prevRowId = getPrevRowId(rowId); + + void rowService.createRow(viewId, { + startRowId: prevRowId || undefined, + }); + }, [getPrevRowId, rowId, viewId]); + + const handleDelRow = useCallback(() => { + void rowService.deleteRow(viewId, rowId); + }, [viewId, rowId]); + + const handleDuplicateRow = useCallback(() => { + void rowService.duplicateRow(viewId, rowId); + }, [viewId, rowId]); + + const options: Option[] = [ + { + label: t('grid.row.insertRecordAbove'), + icon: , + onClick: handleInsertRecordAbove, + }, + { + label: t('grid.row.insertRecordBelow'), + icon: , + onClick: handleInsertRecordBelow, + }, + { + label: t('grid.row.duplicate'), + icon: , + onClick: handleDuplicateRow, + }, + + { + label: t('grid.row.delete'), + icon: , + onClick: handleDelRow, + divider: true, + }, + ]; + + return ( + + {options.map((option) => ( +
+ {option.divider &&
} + { + option.onClick(); + onClickItem(option.label); + }} + > + {option.icon} + {option.label} + +
+ ))} + + ); +} + +export default GridCellRowMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/index.ts new file mode 100644 index 000000000000..2b5749c5aa2a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/index.ts @@ -0,0 +1 @@ +export * from './GridCellRow'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRowActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRowActions.tsx deleted file mode 100644 index 65de7de4b20b..000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRowActions.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { IconButton, Tooltip } from '@mui/material'; -import { FC, PropsWithChildren, useCallback } from 'react'; -import { ReactComponent as AddSvg } from '$app/assets/add.svg'; -import * as service from '$app/components/database/database_bd_svc'; -import { useViewId } from '../../database.hooks'; -import { t } from 'i18next'; - -export interface GridCellRowActionsProps { - className?: string; - rowId: string; -} - -export const GridCellRowActions: FC> = ({ - className, - rowId, - children, -}) => { - const viewId = useViewId(); - - const handleInsertRowClick = useCallback(() => { - void service.createRow(viewId, { - startRowId: rowId, - }); - }, [viewId, rowId]); - - return ( -
- - - - - - {children} -
- ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridFieldRow.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridFieldRow.tsx index 04df6e283b58..a64de4a15ef2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridFieldRow.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridFieldRow.tsx @@ -1,40 +1,40 @@ -import { Virtualizer } from '@tanstack/react-virtual'; -import { FC } from 'react'; import { Button } from '@mui/material'; import { FieldType } from '@/services/backend'; import { ReactComponent as AddSvg } from '$app/assets/add.svg'; -import * as service from '$app/components/database/database_bd_svc'; -import { useDatabase } from '../../database.hooks'; -import { VirtualizedList } from '../../_shared'; +import { fieldService } from '../../application'; +import { useDatabaseVisibilityFields } from '../../Database.hooks'; import { GridField } from '../GridField'; +import { useViewId } from '@/appflowy_app/hooks'; +import { useTranslation } from 'react-i18next'; +import { DEFAULT_FIELD_WIDTH } from '$app/components/database/grid/GridRow/constants'; -export interface GridFieldRowProps { - virtualizer: Virtualizer; -} +export const GridFieldRow = () => { + const { t } = useTranslation(); + const viewId = useViewId(); + const fields = useDatabaseVisibilityFields(); -export const GridFieldRow: FC = ({ - virtualizer, -}) => { - const { viewId, fields } = useDatabase(); const handleClick = async () => { - await service.createFieldTypeOption(viewId, FieldType.RichText); + await fieldService.createField(viewId, FieldType.RichText); }; return ( -
- } - /> -
+
+
+ {fields.map((field) => { + return ; + })} +
+ +
); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridNewRow.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridNewRow.tsx index cf9dd8788b6a..051adde067d2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridNewRow.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridNewRow.tsx @@ -1,29 +1,30 @@ -import { useCallback } from 'react'; +import { FC, useCallback } from 'react'; import { t } from 'i18next'; import { Button } from '@mui/material'; import { ReactComponent as AddSvg } from '$app/assets/add.svg'; -import * as service from '$app/components/database/database_bd_svc'; -import { useDatabase, useViewId } from '../../database.hooks'; +import { useViewId } from '$app/hooks'; +import { rowService } from '../../application'; -export const GridNewRow = () => { +export interface GridNewRowProps { + startRowId?: string; + groupId?: string; +} + +export const GridNewRow: FC = ({ startRowId, groupId }) => { const viewId = useViewId(); - const { rows } = useDatabase(); - const lastRowId = rows.at(-1)?.id; const handleClick = useCallback(() => { - void service.createRow(viewId, { - startRowId: lastRowId, + void rowService.createRow(viewId, { + startRowId, + groupId, }); - }, [viewId, lastRowId]); + }, [viewId, groupId, startRowId]); return ( -
- diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridRow.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridRow.tsx index a1749451d33e..b34fe5f2b15e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridRow.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridRow.tsx @@ -2,33 +2,25 @@ import { Virtualizer } from '@tanstack/react-virtual'; import { FC } from 'react'; import { RenderRow, RenderRowType } from './constants'; import { GridCellRow } from './GridCellRow'; -import { GridFieldRow } from './GridFieldRow'; import { GridNewRow } from './GridNewRow'; -import { GridCalculateRow } from './GridCalculateRow'; +import { GridFieldRow } from '$app/components/database/grid/GridRow/GridFieldRow'; +import GridCalculateRow from '$app/components/database/grid/GridRow/GridCalculateRow'; export interface GridRowProps { row: RenderRow; - virtualizer: Virtualizer; + virtualizer: Virtualizer; + getPrevRowId: (id: string) => string | null; } -export const GridRow: FC = ({ - row, - virtualizer, -}) => { - +export const GridRow: FC = ({ row, virtualizer, getPrevRowId }) => { switch (row.type) { - case RenderRowType.Row: - return ( - - ); case RenderRowType.Fields: - return ; + return ; + case RenderRowType.Row: + return ; case RenderRowType.NewRow: - return ; - case RenderRowType.Calculate: + return ; + case RenderRowType.CalculateRow: return ; default: return null; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/constants.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/constants.ts index e8ae30c1dd83..ba0b0ea96678 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/constants.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/constants.ts @@ -1,10 +1,18 @@ -import { Database } from '$app/interfaces/database'; +import { RowMeta } from '../../application'; + +export const GridCalculateCountHeight = 40; + +export const DEFAULT_FIELD_WIDTH = 150; export enum RenderRowType { Fields = 'fields', Row = 'row', NewRow = 'new-row', - Calculate = 'calculate', + CalculateRow = 'calculate-row', +} + +export interface CalculateRenderRow { + type: RenderRowType.CalculateRow; } export interface FieldRenderRow { @@ -13,15 +21,40 @@ export interface FieldRenderRow { export interface CellRenderRow { type: RenderRowType.Row; - data: Database.Row; + data: { + meta: RowMeta; + }; } export interface NewRenderRow { type: RenderRowType.NewRow; -} - -export interface CalculateRenderRow { - type: RenderRowType.Calculate; + data: { + startRowId?: string; + groupId?: string; + }; } export type RenderRow = FieldRenderRow | CellRenderRow | NewRenderRow | CalculateRenderRow; + +export const rowMetasToRenderRow = (rowMetas: RowMeta[]): RenderRow[] => { + return [ + { + type: RenderRowType.Fields, + }, + ...rowMetas.map((rowMeta) => ({ + type: RenderRowType.Row, + data: { + meta: rowMeta, + }, + })), + { + type: RenderRowType.NewRow, + data: { + startRowId: rowMetas.at(-1)?.id, + }, + }, + { + type: RenderRowType.CalculateRow, + }, + ]; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridTable/GridTable.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridTable/GridTable.tsx index 587e2ec8ea14..5a5d7cfeb603 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridTable/GridTable.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridTable/GridTable.tsx @@ -1,83 +1,76 @@ import { useVirtualizer } from '@tanstack/react-virtual'; -import { FC, useContext, useMemo, useRef } from 'react'; -import { VerticalScrollElementRefContext } from '../../database.context'; -import { useDatabase } from '../../database.hooks'; +import { FC, useMemo, useRef } from 'react'; +import { RowMeta } from '../../application'; +import { useDatabase, useDatabaseVisibilityFields } from '../../Database.hooks'; import { VirtualizedList } from '../../_shared'; -import { GridRow, RenderRow, RenderRowType } from '../GridRow'; +import { DEFAULT_FIELD_WIDTH, GridRow, RenderRow, RenderRowType, rowMetasToRenderRow } from '../GridRow'; const getRenderRowKey = (row: RenderRow) => { if (row.type === RenderRowType.Row) { - return `row:${row.data.id}`; + return `row:${row.data.meta.id}`; } return row.type; }; -const getRenderRowHeight = (row: RenderRow) => { - const defaultRowHeight = 37; - - if (row.type === RenderRowType.Row) { - return row.data.height ?? defaultRowHeight; - } - - return defaultRowHeight; -}; - -export const GridTable: FC = () => { - const verticalScrollElementRef = useContext(VerticalScrollElementRefContext); - const horizontalScrollElementRef = useRef(null); - const { rows, fields } = useDatabase(); - - const renderRows = useMemo(() => { - return [ - { - type: RenderRowType.Fields, - }, - ...rows.map(row => ({ - type: RenderRowType.Row, - data: row, - })), - { - type: RenderRowType.NewRow, - }, - { - type: RenderRowType.Calculate, - }, - ]; - }, [rows]); - - const rowVirtualizer = useVirtualizer({ +export const GridTable: FC<{ tableHeight: number }> = ({ tableHeight }) => { + const verticalScrollElementRef = useRef(null); + const horizontalScrollElementRef = useRef(null); + const { rowMetas } = useDatabase(); + const renderRows = useMemo(() => rowMetasToRenderRow(rowMetas as RowMeta[]), [rowMetas]); + const fields = useDatabaseVisibilityFields(); + const rowVirtualizer = useVirtualizer({ count: renderRows.length, - overscan: 10, - getItemKey: i => getRenderRowKey(renderRows[i]), + overscan: 20, + getItemKey: (i) => getRenderRowKey(renderRows[i]), getScrollElement: () => verticalScrollElementRef.current, - estimateSize: i => getRenderRowHeight(renderRows[i]), + estimateSize: () => 37, }); - const defaultColumnWidth = 221; - const columnVirtualizer = useVirtualizer({ + const columnVirtualizer = useVirtualizer({ horizontal: true, count: fields.length, overscan: 5, - getItemKey: i => fields[i].id, + getItemKey: (i) => fields[i].id, getScrollElement: () => horizontalScrollElementRef.current, - estimateSize: (i) => fields[i].width ?? defaultColumnWidth, + estimateSize: (i) => { + return fields[i].width ?? DEFAULT_FIELD_WIDTH; + }, }); + const getPrevRowId = (id: string) => { + const index = rowMetas.findIndex((rowMeta) => rowMeta.id === id); + + if (index === 0) { + return null; + } + + return rowMetas[index - 1].id; + }; + return (
- ( - - )} - /> +
{ + verticalScrollElementRef.current = e; + horizontalScrollElementRef.current = e; + }} + > + ( + + )} + /> +
); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridToolbar/GridToolbar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridToolbar/GridToolbar.tsx deleted file mode 100644 index a9ef34b76929..000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridToolbar/GridToolbar.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { t } from 'i18next'; -import { Button, Input, IconButton } from '@mui/material'; -import { ReactComponent as AddSvg } from '$app/assets/add.svg'; -import { ReactComponent as SearchSvg } from '$app/assets/search.svg'; -import { ReactComponent as SettingsSvg } from '$app/assets/settings.svg'; - -export const GridToolbar = () => { - // TODO: get view title - const title = 'My plans on week'; - - return ( -
-
- - {title} - - - - - - -
-
- - } - /> -
-
- ); -}; \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridToolbar/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridToolbar/index.ts deleted file mode 100644 index e4a94567a0e1..000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridToolbar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './GridToolbar'; \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/index.ts index fafeee00d449..42a6f3159281 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/index.ts @@ -1,3 +1,3 @@ -export * from './database.context'; -export * from './database.hooks'; +export * from './Database.hooks'; export * from './Database'; +export * from './DatabaseTitle'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/proxy/grid/ui_state/Provider.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/proxy/grid/ui_state/Provider.tsx new file mode 100644 index 000000000000..b46247649438 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/proxy/grid/ui_state/Provider.tsx @@ -0,0 +1,14 @@ +import React, { useEffect } from 'react'; +import { GridUIContext, useProxyGridUIState } from '$app/components/database/proxy/grid/ui_state/actions'; + +function GridUIProvider({ children, isActivated }: { children: React.ReactNode; isActivated: boolean }) { + const context = useProxyGridUIState(); + + useEffect(() => { + context.isActivated = isActivated; + }, [isActivated, context]); + + return {children}; +} + +export default GridUIProvider; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/proxy/grid/ui_state/actions.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/proxy/grid/ui_state/actions.ts new file mode 100644 index 000000000000..65ecee0570dd --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/proxy/grid/ui_state/actions.ts @@ -0,0 +1,46 @@ +import { useMemo, useContext, createContext, useCallback } from 'react'; +import { proxy, useSnapshot } from 'valtio'; + +export interface GridUIContextState { + hoverRowId: string | null; + isActivated: boolean; +} + +const initialUIState: GridUIContextState = { + hoverRowId: null, + isActivated: false, +}; + +function proxyGridUIState(state: GridUIContextState) { + return proxy(state); +} + +export const GridUIContext = createContext(proxyGridUIState(initialUIState)); + +export function useProxyGridUIState() { + const context = useMemo(() => { + return proxyGridUIState({ + ...initialUIState, + }); + }, []); + + return context; +} + +export function useGridUIStateSelector() { + return useSnapshot(useContext(GridUIContext)); +} + +export function useGridUIStateDispatcher() { + const context = useContext(GridUIContext); + const setRowHover = useCallback( + (rowId: string | null) => { + context.hoverRowId = rowId; + }, + [context] + ); + + return { + setRowHover, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.hooks.ts index 5122af621b1c..f0258354c156 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.hooks.ts @@ -90,7 +90,7 @@ export function useBlockRectSelection({ container, getIntersectedBlockIds }: Blo const blockIds = getIntersectedBlockIds(newRect); setRect(newRect); - dispatch( + void dispatch( setRectSelectionThunk({ selection: blockIds, docId, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts index d528e41050ba..a0824836bdec 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts @@ -16,6 +16,7 @@ export function useNodesRect(container: HTMLDivElement) { (node: Element) => { const { x, y, width, height } = node.getBoundingClientRect(); const id = node.getAttribute('data-block-id'); + if (!id) return; const rect = { id, @@ -24,6 +25,7 @@ export function useNodesRect(container: HTMLDivElement) { width, height, }; + regionGrid?.updateBlock(rect); }, [container.scrollLeft, container.scrollTop, regionGrid] @@ -31,6 +33,7 @@ export function useNodesRect(container: HTMLDivElement) { const updateViewPortNodesRect = useCallback(() => { const nodes = container.querySelectorAll('[data-block-id]'); + nodes.forEach(updateNodeRect); }, [container, updateNodeRect]); @@ -55,6 +58,7 @@ export function useNodesRect(container: HTMLDivElement) { const y = Math.min(startY, endY); const width = Math.abs(endX - startX); const height = Math.abs(endY - startY); + return regionGrid .getIntersectingBlocks({ x, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts index 8a85fcb71a26..21d18b07b6dc 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts @@ -2,7 +2,6 @@ import { useCallback, useMemo } from 'react'; import { Keyboard } from '$app/constants/document/keyboard'; import { useAppDispatch } from '$app/stores/store'; import { arrowActionForRangeThunk, deleteRangeAndInsertThunk } from '$app_reducers/document/async-actions'; -import Delta from 'quill-delta'; import isHotkey from 'is-hotkey'; import { deleteRangeAndInsertEnterThunk } from '$app_reducers/document/async-actions/range'; import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks'; @@ -26,7 +25,7 @@ export function useRangeKeyDown() { }, handler: (_: KeyboardEvent) => { if (!controller) return; - dispatch( + void dispatch( deleteRangeAndInsertThunk({ controller, }) @@ -40,7 +39,7 @@ export function useRangeKeyDown() { }, handler: (e: KeyboardEvent) => { if (!controller) return; - dispatch( + void dispatch( deleteRangeAndInsertThunk({ controller, insertChar: e.key, @@ -53,9 +52,9 @@ export function useRangeKeyDown() { canHandle: (e: KeyboardEvent) => { return isHotkey(Keyboard.keys.SHIFT_ENTER, e); }, - handler: (e: KeyboardEvent) => { + handler: () => { if (!controller) return; - dispatch( + void dispatch( deleteRangeAndInsertEnterThunk({ controller, shiftKey: true, @@ -68,9 +67,9 @@ export function useRangeKeyDown() { canHandle: (e: KeyboardEvent) => { return isHotkey(Keyboard.keys.ENTER, e); }, - handler: (e: KeyboardEvent) => { + handler: () => { if (!controller) return; - dispatch( + void dispatch( deleteRangeAndInsertEnterThunk({ controller, shiftKey: false, @@ -89,7 +88,7 @@ export function useRangeKeyDown() { ); }, handler: (e: KeyboardEvent) => { - dispatch( + void dispatch( arrowActionForRangeThunk({ key: e.key, docId, @@ -105,7 +104,7 @@ export function useRangeKeyDown() { const format = parseFormat(e); if (!format) return; - dispatch( + void dispatch( toggleFormatThunk({ format, controller, @@ -117,7 +116,7 @@ export function useRangeKeyDown() { [controller, dispatch, docId] ); - const onKeyDownCapture = useCallback( + return useCallback( (e: KeyboardEvent) => { if (!rangeRef.current) { return; @@ -147,6 +146,4 @@ export function useRangeKeyDown() { }, [interceptEvents, rangeRef] ); - - return onKeyDownCapture; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx index cef2c021841e..932a3ad3304c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx @@ -93,9 +93,7 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) { if (hovered) { const option = options.find((option) => option.key === hovered); - if (option) { - option.operate?.(); - } + void option?.operate?.(); } else { onClose(); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx index 274ab960de8e..9ccd26389934 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx @@ -39,6 +39,10 @@ export function useBlockSideToolbar(id: string) { return -6; } + if (block.type === BlockType.GridBlock) { + return 16; + } + return 0; }, [docId, id]); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx index b48d34516d29..ed7114f0d8c3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx @@ -38,7 +38,7 @@ export default function BlockSideToolbar({ id }: { id: string }) { pointerEvents: show ? 'auto' : 'none', }} onClick={(_: React.MouseEvent) => { - dispatch( + void dispatch( addBlockBelowClickThunk({ id, controller, @@ -74,15 +74,14 @@ export default function BlockSideToolbar({ id }: { id: string }) { pointerEvents: show ? 'auto' : 'none', }} data-draggable-anchor={id} - onClick={(e: React.MouseEvent) => { - dispatch( + onClick={async (e: React.MouseEvent) => { + handleOpen(e); + await dispatch( setRectSelectionThunk({ docId, selection: [id], }) ); - - handleOpen(e); }} sx={{ height: 24, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx index 234ff085f847..fcee83dc258b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useMemo } from 'react'; import MenuItem from '$app/components/document/_shared/MenuItem'; import { ArrowRight, @@ -13,6 +13,7 @@ import { SafetyDivider, Image, Functions, + BackupTableOutlined, } from '@mui/icons-material'; import { BlockData, @@ -23,31 +24,31 @@ import { } from '$app/interfaces/document'; import { useAppDispatch } from '$app/stores/store'; import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { slashCommandActions } from '$app_reducers/document/slice'; -import { Keyboard } from '$app/constants/document/keyboard'; -import { selectOptionByUpDown } from '$app/utils/document/menu'; import { turnToBlockThunk } from '$app_reducers/document/async-actions'; -import {useTranslation} from "react-i18next"; +import { useTranslation } from 'react-i18next'; +import { useKeyboardShortcut } from '$app/components/document/BlockSlash/index.hooks'; function BlockSlashMenu({ id, onClose, searchText, hoverOption, + onHoverOption, container, }: { id: string; onClose?: () => void; searchText?: string; hoverOption?: SlashCommandOption; + onHoverOption: (option: SlashCommandOption, target: HTMLElement) => void; container: HTMLDivElement; }) { const dispatch = useAppDispatch(); - const { t } = useTranslation() - const ref = useRef(null); - const { docId, controller } = useSubscribeDocument(); + const { t } = useTranslation(); + const { controller } = useSubscribeDocument(); + const handleInsert = useCallback( - async (type: BlockType, data?: BlockData) => { + async (type: BlockType, data?: BlockData) => { if (!controller) return; await dispatch( turnToBlockThunk({ @@ -72,14 +73,14 @@ function BlockSlashMenu({ { key: SlashCommandOptionKey.TEXT, type: BlockType.TextBlock, - title: 'Text', + title: t('editor.text'), icon: , group: SlashCommandGroup.BASIC, }, { key: SlashCommandOptionKey.HEADING_1, type: BlockType.HeadingBlock, - title: 'Heading 1', + title: t('editor.heading1'), icon: , data: { level: 1, @@ -89,7 +90,7 @@ function BlockSlashMenu({ { key: SlashCommandOptionKey.HEADING_2, type: BlockType.HeadingBlock, - title: 'Heading 2', + title: t('editor.heading2'), icon: <Title />, data: { level: 2, @@ -99,7 +100,7 @@ function BlockSlashMenu({ { key: SlashCommandOptionKey.HEADING_3, type: BlockType.HeadingBlock, - title: 'Heading 3', + title: t('editor.heading3'), icon: <Title />, data: { level: 3, @@ -109,35 +110,35 @@ function BlockSlashMenu({ { key: SlashCommandOptionKey.TODO, type: BlockType.TodoListBlock, - title: 'To-do list', + title: t('editor.checkbox'), icon: <Check />, group: SlashCommandGroup.BASIC, }, { key: SlashCommandOptionKey.BULLET, type: BlockType.BulletedListBlock, - title: 'Bulleted list', + title: t('editor.bulletedList'), icon: <FormatListBulleted />, group: SlashCommandGroup.BASIC, }, { key: SlashCommandOptionKey.NUMBER, type: BlockType.NumberedListBlock, - title: 'Numbered list', + title: t('editor.numberedList'), icon: <FormatListNumbered />, group: SlashCommandGroup.BASIC, }, { key: SlashCommandOptionKey.TOGGLE, type: BlockType.ToggleListBlock, - title: 'Toggle list', + title: t('document.plugins.toggleList'), icon: <ArrowRight />, group: SlashCommandGroup.BASIC, }, { key: SlashCommandOptionKey.QUOTE, type: BlockType.QuoteBlock, - title: 'Quote', + title: t('toolbar.quote'), icon: <FormatQuote />, group: SlashCommandGroup.BASIC, }, @@ -151,31 +152,41 @@ function BlockSlashMenu({ { key: SlashCommandOptionKey.DIVIDER, type: BlockType.DividerBlock, - title: 'Divider', + title: t('editor.divider'), icon: <SafetyDivider />, group: SlashCommandGroup.BASIC, }, { key: SlashCommandOptionKey.CODE, type: BlockType.CodeBlock, - title: 'Code', + title: t('document.selectionMenu.codeBlock'), icon: <DataObject />, group: SlashCommandGroup.MEDIA, }, { key: SlashCommandOptionKey.IMAGE, type: BlockType.ImageBlock, - title: 'Image', + title: t('editor.image'), icon: <Image />, group: SlashCommandGroup.MEDIA, }, { key: SlashCommandOptionKey.EQUATION, type: BlockType.EquationBlock, - title: 'Block equation', + title: t('document.plugins.mathEquation.addMathEquation'), icon: <Functions />, group: SlashCommandGroup.ADVANCED, }, + { + key: SlashCommandOptionKey.GRID_REFERENCE, + type: BlockType.GridBlock, + title: t('document.plugins.referencedGrid'), + icon: <BackupTableOutlined />, + group: SlashCommandGroup.ADVANCED, + onClick: () => { + // do nothing + }, + }, ].filter((option) => { if (!searchText) return true; const match = (text: string) => { @@ -184,9 +195,16 @@ function BlockSlashMenu({ return match(option.title) || match(option.type); }), - [searchText] + [searchText, t] ); + const { ref } = useKeyboardShortcut({ + container, + options, + handleInsert, + hoverOption, + }); + const optionsByGroup = useMemo(() => { return options.reduce((acc, option) => { if (!acc[option.group]) { @@ -198,93 +216,10 @@ function BlockSlashMenu({ }, {} as Record<SlashCommandGroup, typeof options>); }, [options]); - const scrollIntoOption = useCallback((option: SlashCommandOption) => { - if (!ref.current) return; - const containerRect = ref.current.getBoundingClientRect(); - const optionElement = document.querySelector(`#slash-item-${option.key}`); - - if (!optionElement) return; - const itemRect = optionElement?.getBoundingClientRect(); - - if (!itemRect) return; - - if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) { - optionElement.scrollIntoView({ behavior: 'smooth' }); - } - }, []); - - const selectOptionByArrow = useCallback( - ({ isUp = false, isDown = false }: { isUp?: boolean; isDown?: boolean }) => { - if (!isUp && !isDown) return; - const optionsKeys = options.map((option) => String(option.key)); - const nextKey = selectOptionByUpDown(isUp, String(hoverOption?.key), optionsKeys); - const nextOption = options.find((option) => String(option.key) === nextKey); - - if (!nextOption) return; - - scrollIntoOption(nextOption); - dispatch( - slashCommandActions.setHoverOption({ - option: nextOption, - docId, - }) - ); - }, - [dispatch, docId, hoverOption?.key, options, scrollIntoOption] - ); - - useEffect(() => { - const handleKeyDownCapture = (e: KeyboardEvent) => { - const isUp = e.key === Keyboard.keys.UP; - const isDown = e.key === Keyboard.keys.DOWN; - const isEnter = e.key === Keyboard.keys.ENTER; - - // if any arrow key is pressed, prevent default behavior and stop propagation - if (isUp || isDown || isEnter) { - e.stopPropagation(); - e.preventDefault(); - if (isEnter) { - if (hoverOption) { - handleInsert(hoverOption.type, hoverOption.data); - } - - return; - } - - selectOptionByArrow({ - isUp, - isDown, - }); - } - }; - - // intercept keydown event in capture phase before it reaches the editor - container.addEventListener('keydown', handleKeyDownCapture, true); - return () => { - container.removeEventListener('keydown', handleKeyDownCapture, true); - }; - }, [container, handleInsert, hoverOption, selectOptionByArrow]); - - const onHoverOption = useCallback( - (option: SlashCommandOption) => { - dispatch( - slashCommandActions.setHoverOption({ - option: { - key: option.key, - type: option.type, - data: option.data, - }, - docId, - }) - ); - }, - [dispatch, docId] - ); - const renderEmptyContent = useCallback(() => { - return <div className={'m-5 text-text-caption flex justify-center items-center'}> - {t('findAndReplace.noResult')} - </div> + return ( + <div className={'m-5 flex items-center justify-center text-text-caption'}>{t('findAndReplace.noResult')}</div> + ); }, [t]); return ( @@ -296,30 +231,37 @@ function BlockSlashMenu({ className={'flex h-[100%] max-h-[40vh] w-[324px] min-w-[180px] max-w-[calc(100vw-32px)] flex-col p-1'} > <div ref={ref} className={'min-h-0 flex-1 overflow-y-auto overflow-x-hidden'}> - {options.length === 0 ? renderEmptyContent(): Object.entries(optionsByGroup).map(([group, options]) => ( - <div key={group}> - <div className={'px-2 py-2 text-sm text-text-caption'}>{group}</div> - <div> - {options.map((option) => { - return ( - <MenuItem - id={`slash-item-${option.key}`} - key={option.key} - title={option.title} - icon={option.icon} - onHover={() => { - onHoverOption(option); - }} - isHovered={hoverOption?.key === option.key} - onClick={() => { - handleInsert(option.type, option.data); - }} - /> - ); - })} - </div> - </div> - ))} + {options.length === 0 + ? renderEmptyContent() + : Object.entries(optionsByGroup).map(([group, options]) => ( + <div key={group}> + <div className={'px-2 py-2 text-sm text-text-caption'}>{group}</div> + <div> + {options.map((option) => { + return ( + <MenuItem + id={`slash-item-${option.key}`} + key={option.key} + title={option.title} + icon={option.icon} + onHover={(e) => { + onHoverOption(option, e.currentTarget as HTMLElement); + }} + isHovered={hoverOption?.key === option.key} + onClick={() => { + if (!option.onClick) { + void handleInsert(option.type, option.data); + return; + } + + option.onClick(); + }} + /> + ); + })} + </div> + </div> + ))} </div> </div> ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts index 10fa7a0297f7..fc9ccd719ecc 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts @@ -1,9 +1,101 @@ import { useAppDispatch } from '$app/stores/store'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { slashCommandActions } from '$app_reducers/document/slice'; import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; import { useSubscribeSlashState } from '$app/components/document/_shared/SubscribeSlash.hooks'; import { useSubscribePanelSearchText } from '$app/components/document/_shared/usePanelSearchText'; +import { BlockData, BlockType, SlashCommandOption, SlashCommandOptionKey } from '$app/interfaces/document'; +import { selectOptionByUpDown } from '$app/utils/document/menu'; +import { Keyboard } from '$app/constants/document/keyboard'; + +export function useKeyboardShortcut({ + container, + options, + handleInsert, + hoverOption, +}: { + container: HTMLElement; + options: SlashCommandOption[]; + handleInsert: (type: BlockType, data?: BlockData) => Promise<void>; + hoverOption?: SlashCommandOption; +}) { + const ref = useRef<HTMLDivElement | null>(null); + const dispatch = useAppDispatch(); + const { docId } = useSubscribeDocument(); + const scrollIntoOption = useCallback( + (option: SlashCommandOption) => { + if (!ref.current) return; + const containerRect = ref.current.getBoundingClientRect(); + const optionElement = document.querySelector(`#slash-item-${option.key}`); + + if (!optionElement) return; + const itemRect = optionElement?.getBoundingClientRect(); + + if (!itemRect) return; + + if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) { + optionElement.scrollIntoView({ behavior: 'smooth' }); + } + }, + [ref] + ); + + const selectOptionByArrow = useCallback( + ({ isUp = false, isDown = false }: { isUp?: boolean; isDown?: boolean }) => { + if (!isUp && !isDown) return; + const optionsKeys = options.map((option) => String(option.key)); + const nextKey = selectOptionByUpDown(isUp, String(hoverOption?.key), optionsKeys); + const nextOption = options.find((option) => String(option.key) === nextKey); + + if (!nextOption) return; + + scrollIntoOption(nextOption); + dispatch( + slashCommandActions.setHoverOption({ + option: nextOption, + docId, + }) + ); + }, + [dispatch, docId, hoverOption?.key, options, scrollIntoOption] + ); + + useEffect(() => { + const handleKeyDownCapture = (e: KeyboardEvent) => { + const isUp = e.key === Keyboard.keys.UP; + const isDown = e.key === Keyboard.keys.DOWN; + const isEnter = e.key === Keyboard.keys.ENTER; + + // if any arrow key is pressed, prevent default behavior and stop propagation + if (isUp || isDown || isEnter) { + e.stopPropagation(); + e.preventDefault(); + if (isEnter) { + if (hoverOption) { + void handleInsert(hoverOption.type, hoverOption.data); + } + + return; + } + + selectOptionByArrow({ + isUp, + isDown, + }); + } + }; + + // intercept keydown event in capture phase before it reaches the editor + container.addEventListener('keydown', handleKeyDownCapture, true); + return () => { + container.removeEventListener('keydown', handleKeyDownCapture, true); + }; + }, [container, handleInsert, hoverOption, selectOptionByArrow]); + + return { + ref, + }; +} export function useBlockSlash() { const dispatch = useAppDispatch(); @@ -13,6 +105,10 @@ export function useBlockSlash() { top: number; left: number; }>(); + const [subMenuAnchorPosition, setSubMenuAnchorPosition] = useState<{ + top: number; + left: number; + }>(); useEffect(() => { if (blockId && visible) { @@ -41,11 +137,42 @@ export function useBlockSlash() { }, [slashText]); const onClose = useCallback(() => { + setSubMenuAnchorPosition(undefined); dispatch(slashCommandActions.closeSlashCommand(docId)); }, [dispatch, docId]); const open = Boolean(anchorPosition); + const onHoverOption = useCallback( + (option: SlashCommandOption, target: HTMLElement) => { + setSubMenuAnchorPosition(undefined); + dispatch( + slashCommandActions.setHoverOption({ + option: { + key: option.key, + type: option.type, + data: option.data, + }, + docId, + }) + ); + + if (option.key === SlashCommandOptionKey.GRID_REFERENCE) { + const rect = target.getBoundingClientRect(); + + setSubMenuAnchorPosition({ + top: rect.top, + left: rect.right, + }); + } + }, + [dispatch, docId] + ); + + const onCloseSubMenu = useCallback(() => { + setSubMenuAnchorPosition(undefined); + }, []); + return { open, anchorPosition, @@ -53,6 +180,9 @@ export function useBlockSlash() { blockId, searchText, hoverOption, + onHoverOption, + onCloseSubMenu, + subMenuAnchorPosition, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.tsx index 9bdf418b2897..1566b1156b60 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.tsx @@ -1,10 +1,33 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import Popover from '@mui/material/Popover'; import BlockSlashMenu from '$app/components/document/BlockSlash/BlockSlashMenu'; import { useBlockSlash } from '$app/components/document/BlockSlash/index.hooks'; +import { SlashCommandOptionKey } from '$app/interfaces/document'; +import DatabaseList from '$app/components/document/_shared/DatabaseList'; +import { ViewLayoutPB } from '@/services/backend'; function BlockSlash({ container }: { container: HTMLDivElement }) { - const { blockId, open, onClose, anchorPosition, searchText, hoverOption } = useBlockSlash(); + const { + blockId, + open, + onClose, + anchorPosition, + searchText, + hoverOption, + onHoverOption, + subMenuAnchorPosition, + onCloseSubMenu, + } = useBlockSlash(); + + const renderSubMenu = useCallback(() => { + if (!blockId) return null; + switch (hoverOption?.key) { + case SlashCommandOptionKey.GRID_REFERENCE: + return <DatabaseList onClose={onClose} blockId={blockId} layout={ViewLayoutPB.Grid} searchText={searchText} />; + default: + return null; + } + }, [blockId, hoverOption?.key, onClose, searchText]); if (!blockId) return null; @@ -26,7 +49,29 @@ function BlockSlash({ container }: { container: HTMLDivElement }) { id={blockId} onClose={onClose} searchText={searchText} + onHoverOption={onHoverOption} /> + <Popover + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + disableAutoFocus + sx={{ + pointerEvents: 'none', + }} + PaperProps={{ + style: { + pointerEvents: 'auto', + }, + }} + open={!!subMenuAnchorPosition} + anchorReference={'anchorPosition'} + anchorPosition={subMenuAnchorPosition} + onClose={onCloseSubMenu} + > + {renderSubMenu()} + </Popover> </Popover> ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/SelectLanguage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/SelectLanguage.tsx index f1a5132818ba..01a96028df7b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/SelectLanguage.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/SelectLanguage.tsx @@ -17,7 +17,7 @@ function SelectLanguage({ id, language }: { id: string; language: string }) { if (!controller) return; const language = event.target.value; - dispatch( + void dispatch( updateNodeDataThunk({ id, controller, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx index eea05bb50cc1..907c671c7bd0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx @@ -8,7 +8,7 @@ import { useSelection } from '$app/components/document/_shared/EditorHooks/useSe import { useAppSelector } from '$app/stores/store'; import { ThemeMode } from '$app/interfaces'; -export default function CodeBlock({ +export default React.memo(function CodeBlock({ node, placeholder, ...props @@ -40,4 +40,4 @@ export default function CodeBlock({ /> </div> ); -} +}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts index 2a64f350deeb..a24413a69bc5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts @@ -1,5 +1,5 @@ import isHotkey from 'is-hotkey'; -import { useCallback, useContext, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useAppDispatch } from '$app/stores/store'; import { Keyboard } from '$app/constants/document/keyboard'; import { useCommonKeyEvents } from '$app/components/document/_shared/EditorHooks/useCommonKeyEvents'; @@ -8,7 +8,7 @@ import { useSubscribeDocument } from '$app/components/document/_shared/Subscribe export function useKeyDown(id: string) { const dispatch = useAppDispatch(); - const { docId, controller } = useSubscribeDocument(); + const { controller } = useSubscribeDocument(); const commonKeyEvents = useCommonKeyEvents(id); const customEvents = useMemo(() => { @@ -22,7 +22,7 @@ export function useKeyDown(id: string) { handler: (e: React.KeyboardEvent<HTMLDivElement>) => { e.preventDefault(); if (!controller) return; - dispatch( + void dispatch( enterActionForBlockThunk({ id, controller, @@ -37,6 +37,7 @@ export function useKeyDown(id: string) { (e) => { e.stopPropagation(); const keyEvents = [...customEvents]; + keyEvents.forEach((keyEvent) => { // Here we check if the key event can be handled by the current key event if (keyEvent.canHandle(e)) { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ColumnListBlock/Column.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ColumnListBlock/Column.tsx deleted file mode 100644 index f88031e64fd1..000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ColumnListBlock/Column.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import NodeComponent from '$app/components/document/Node'; -import React from 'react'; - -export function ColumnBlock({ id, index, width }: { id: string; index: number; width: string }) { - const renderResizer = () => { - return ( - <div className={`relative w-[46px] flex-shrink-0 flex-grow-0 transition-opacity`} style={{ opacity: 0 }}></div> - ); - }; - - return ( - <> - {index === 0 ? ( - <div className='contents'> - <div - className='absolute flex' - style={{ - inset: '0px 100% 0px auto', - }} - > - {renderResizer()} - </div> - </div> - ) : ( - renderResizer() - )} - - <NodeComponent - className={`column-block py-3`} - style={{ - flexGrow: 0, - flexShrink: 0, - width, - }} - id={id} - /> - </> - ); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ColumnListBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ColumnListBlock/index.tsx deleted file mode 100644 index b0c406908bca..000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ColumnListBlock/index.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { useMemo } from 'react'; -import { Node } from '$app/interfaces/document'; -import { ColumnBlock } from './Column'; - -export default function ColumnListBlock({ - node, - childIds, -}: { - node: Node & { - data: Record<string, any>; - }; - childIds?: string[]; -}) { - const resizerWidth = useMemo(() => { - return 46 * (node.children?.length || 0); - }, [node.children?.length]); - return ( - <> - <div className='column-list-block flex-grow-1 flex flex-row'> - {childIds?.map((item, index) => ( - <ColumnBlock - key={item} - index={index} - width={`calc((100% - ${resizerWidth}px) * ${node.data.ratio})`} - id={item} - /> - ))} - </div> - </> - ); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/DocumentBanner.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/DocumentBanner.hooks.ts index 6c57f6ba5be7..200816463a09 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/DocumentBanner.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/DocumentBanner.hooks.ts @@ -6,6 +6,7 @@ import { ViewIconTypePB } from '@/services/backend'; import { CoverType } from '$app/interfaces/document'; import { updateNodeDataThunk } from '$app_reducers/document/async-actions'; import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; + export const heightCls = { cover: 'h-[220px]', icon: 'h-[80px]', @@ -22,7 +23,7 @@ export function useDocumentBanner(id: string) { const onUpdateIcon = useCallback( (icon: string) => { - dispatch( + void dispatch( updatePageIcon({ id: docId, icon: icon @@ -39,7 +40,7 @@ export function useDocumentBanner(id: string) { const onUpdateCover = useCallback( (coverType: CoverType | null, cover: string | null) => { - dispatch( + void dispatch( updateNodeDataThunk({ id, data: { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeImages.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeImages.tsx index 1ff91eaea904..0a3b9d42e73f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeImages.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeImages.tsx @@ -7,7 +7,7 @@ import { readCoverImageUrls, readImage, writeCoverImageUrls } from '$app/utils/d import { Log } from '$app/utils/log'; import { Image } from '$app/components/document/DocumentBanner/cover/GalleryItem'; -function ChangeImages({ cover, onChange }: { onChange: (url: string) => void; cover: string }) { +function ChangeImages({ onChange }: { onChange: (url: string) => void; cover: string }) { const { t } = useTranslation(); const [images, setImages] = useState<Image[]>([]); const loadImageUrls = useCallback(async () => { @@ -58,7 +58,7 @@ function ChangeImages({ cover, onChange }: { onChange: (url: string) => void; co }, [loadImageUrls]); useEffect(() => { - loadImageUrls(); + void loadImageUrls(); }, [loadImageUrls]); return ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/index.tsx index 036ee0e12755..f8d4c269ac86 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/index.tsx @@ -5,6 +5,7 @@ import DocumentIcon from './DocumentIcon'; function DocumentBanner({ id, hover }: { id: string; hover: boolean }) { const { onUpdateCover, node, onUpdateIcon, icon, cover, className, coverType } = useDocumentBanner(id); + return ( <> <div diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/EquationBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/EquationBlock/index.tsx index 8532aaa511ba..489a7aa64177 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/EquationBlock/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/EquationBlock/index.tsx @@ -84,4 +84,4 @@ function EquationBlock({ node }: { node: NestedBlock<BlockType.EquationBlock> }) ); } -export default EquationBlock; +export default React.memo(EquationBlock); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/GridBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/GridBlock/index.tsx new file mode 100644 index 000000000000..d3c9d10dc1cd --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/GridBlock/index.tsx @@ -0,0 +1,36 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { BlockType, NestedBlock } from '$app/interfaces/document'; +import { Database } from '$app/components/database'; +import { ViewIdProvider } from '@/appflowy_app/hooks'; + +function GridBlock({ node }: { node: NestedBlock<BlockType.GridBlock> }) { + const viewId = node.data.viewId; + const ref = useRef<HTMLDivElement>(null); + const [selectedViewId, onChangeSelectedViewId] = useState(viewId); + + useEffect(() => { + const element = ref.current; + + if (!element) return; + + const resizeObserver = new ResizeObserver(() => { + element.style.minHeight = `${element.clientHeight}px`; + }); + + resizeObserver.observe(element); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + return ( + <div className='flex h-[400px] overflow-hidden py-3 caret-text-title' ref={ref}> + <ViewIdProvider value={viewId}> + <Database selectedViewId={selectedViewId} setSelectedViewId={onChangeSelectedViewId} /> + </ViewIdProvider> + </div> + ); +} + +export default React.memo(GridBlock); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageAlign.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageAlign.tsx index 5217eaa94a1b..bbd2c40c31ed 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageAlign.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageAlign.tsx @@ -47,7 +47,7 @@ function ImageAlign({ const updateAlign = useCallback( (align: Align) => { - dispatch( + void dispatch( updateNodeDataThunk({ id, data: { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageToolbar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageToolbar.tsx index c2175ec3f58e..c9fc838a815c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageToolbar.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageToolbar.tsx @@ -27,7 +27,7 @@ function ImageToolbar({ id, open, align }: { id: string; open: boolean; align: A <Tooltip disableInteractive placement={'top'} title={t('button.delete')}> <div onClick={() => { - dispatch(deleteNodeThunk({ id, controller })); + void dispatch(deleteNodeThunk({ id, controller })); }} className='flex items-center justify-center p-1' > diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/index.tsx index 9aecb525f506..aff3cf4bc667 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/index.tsx @@ -20,7 +20,7 @@ function ImageBlock({ node }: { node: NestedBlock<BlockType.ImageBlock> }) { ({ onClose }: { onClose: () => void }) => { const onSubmitUrl = (url: string) => { if (!url) return; - dispatch( + void dispatch( updateNodeDataThunk({ id, data: { @@ -77,4 +77,4 @@ function ImageBlock({ node }: { node: NestedBlock<BlockType.ImageBlock> }) { ); } -export default ImageBlock; +export default React.memo(ImageBlock); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/useImageBlock.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/useImageBlock.ts index 80c0940f170f..f9b7c7da5dbe 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/useImageBlock.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/useImageBlock.ts @@ -47,7 +47,7 @@ export function useImageBlock(node: NestedBlock<BlockType.ImageBlock>) { const updateWidth = useCallback( (width: number, height: number) => { - dispatch( + void dispatch( updateNodeDataThunk({ id: node.id, data: { @@ -93,7 +93,7 @@ export function useImageBlock(node: NestedBlock<BlockType.ImageBlock>) { }); }; - const onResizeEnd = (e: MouseEvent) => { + const onResizeEnd = () => { setResizing(false); if (!startResizePoint.current) return; startResizePoint.current = undefined; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/NodeChildren.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/NodeChildren.tsx index 7b9978cda7a5..c134058dbab4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/NodeChildren.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/NodeChildren.tsx @@ -11,4 +11,4 @@ function NodeChildren({ childIds, ...props }: { childIds?: string[] } & React.HT ) : null; } -export default NodeChildren; +export default React.memo(NodeChildren); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx index f157619afe66..3dc5f6c50b4b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx @@ -19,6 +19,8 @@ import CodeBlock from '$app/components/document/CodeBlock'; import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks'; import EquationBlock from '$app/components/document/EquationBlock'; import ImageBlock from '$app/components/document/ImageBlock'; +import GridBlock from '$app/components/document/GridBlock'; + import { useTranslation } from 'react-i18next'; import BlockDraggable from '$app/components/_shared/BlockDraggable'; import { BlockDraggableType } from '$app_reducers/block-draggable/slice'; @@ -28,41 +30,31 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H const renderBlock = useCallback(() => { switch (node.type) { - case BlockType.TextBlock: { + case BlockType.TextBlock: return <TextBlock node={node} childIds={childIds} />; - } - case BlockType.HeadingBlock: { + case BlockType.HeadingBlock: return <HeadingBlock node={node} />; - } - case BlockType.TodoListBlock: { + case BlockType.TodoListBlock: return <TodoListBlock node={node} childIds={childIds} />; - } - case BlockType.QuoteBlock: { + case BlockType.QuoteBlock: return <QuoteBlock node={node} childIds={childIds} />; - } - - case BlockType.BulletedListBlock: { + case BlockType.BulletedListBlock: return <BulletedListBlock node={node} childIds={childIds} />; - } - case BlockType.NumberedListBlock: { + case BlockType.NumberedListBlock: return <NumberedListBlock node={node} childIds={childIds} />; - } - case BlockType.ToggleListBlock: { + case BlockType.ToggleListBlock: return <ToggleListBlock node={node} childIds={childIds} />; - } - case BlockType.DividerBlock: { + case BlockType.DividerBlock: return <DividerBlock />; - } - case BlockType.CalloutBlock: { + case BlockType.CalloutBlock: return <CalloutBlock node={node} childIds={childIds} />; - } case BlockType.CodeBlock: return <CodeBlock node={node} />; @@ -70,6 +62,8 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H return <EquationBlock node={node} />; case BlockType.ImageBlock: return <ImageBlock node={node} />; + case BlockType.GridBlock: + return <GridBlock node={node} />; default: return <UnSupportedBlock />; } @@ -102,7 +96,7 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H ); } -const NodeWithErrorBoundary = withErrorBoundary(NodeComponent, { +const NodeWithErrorBoundary = withErrorBoundary(React.memo(NodeComponent), { FallbackComponent: ErrorBoundaryFallbackComponent, }); @@ -116,4 +110,4 @@ const UnSupportedBlock = () => { ); }; -export default React.memo(NodeWithErrorBoundary); +export default NodeWithErrorBoundary; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/NumberedListBlock/NumberedListBlock.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/NumberedListBlock/NumberedListBlock.hooks.ts index 535645655894..f9ff2c55ed93 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/NumberedListBlock/NumberedListBlock.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/NumberedListBlock/NumberedListBlock.hooks.ts @@ -10,16 +10,20 @@ export function useNumberedListBlock(node: NestedBlock<BlockType.NumberedListBlo const documentState = state['document'][docId]; const nodes = documentState.nodes; const children = documentState.children; + + if (!node.parent) return 0; // The parent must be existed - const parent = nodes[node.parent!]; + const parent = nodes[node.parent]; const siblings = children[parent.children]; const index = siblings.indexOf(node.id); + if (index === 0) return 0; const prevNodeIds = siblings.slice(0, index); // The index is distance from last block to the last non-numbered-list block const lastIndex = prevNodeIds.reverse().findIndex((id) => { return nodes[id].type !== BlockType.NumberedListBlock; }); + if (lastIndex === -1) return prevNodeIds.length; return lastIndex; }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx index 23b9e6720c96..d8683e4d55a4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx @@ -27,8 +27,8 @@ function Root({ documentData }: { documentData: DocumentData }) { ); } -const RootWithErrorBoundary = withErrorBoundary(Root, { +const RootWithErrorBoundary = withErrorBoundary(React.memo(Root), { FallbackComponent: ErrorBoundaryFallbackComponent, }); -export default React.memo(RootWithErrorBoundary); +export default RootWithErrorBoundary; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/CustomColorPicker.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/CustomColorPicker.tsx index ed0e4e6cce2b..eee5f9c126a7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/CustomColorPicker.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/CustomColorPicker.tsx @@ -46,7 +46,7 @@ function CustomColorPicker({ onClose={onClose} > <SketchPicker - onChange={(color, event) => { + onChange={(color) => { setColor(color.rgb); }} color={color} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx index 8180ebc8681f..9d8412e5e309 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx @@ -61,7 +61,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => const addTemporaryInput = useCallback( (type: TemporaryType) => { - dispatch(createTemporary({ type, docId })); + void dispatch(createTemporary({ type, docId })); }, [dispatch, docId] ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts index a1098e99c203..315ccd333758 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts @@ -36,6 +36,7 @@ export function useTextActionMenu() { return groups.map((group) => { return group.filter((item) => items.includes(item)); }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(items), node]); return { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts index 931f81a3962a..0c49ef54e47e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts @@ -10,10 +10,9 @@ import { import { useTurnIntoBlockEvents } from './useTurnIntoBlockEvents'; import { useCommonKeyEvents } from '../_shared/EditorHooks/useCommonKeyEvents'; import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { openMention } from '$app_reducers/document/async-actions/mention'; export function useKeyDown(id: string) { - const { controller, docId } = useSubscribeDocument(); + const { controller } = useSubscribeDocument(); const dispatch = useAppDispatch(); const turnIntoEvents = useTurnIntoBlockEvents(id); const commonKeyEvents = useCommonKeyEvents(id); @@ -34,9 +33,9 @@ export function useKeyDown(id: string) { canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => { return isHotkey(Keyboard.keys.ENTER, e); }, - handler: (e: React.KeyboardEvent<HTMLDivElement>) => { + handler: () => { if (!controller) return; - dispatch( + void dispatch( enterActionForBlockThunk({ id, controller, @@ -58,9 +57,9 @@ export function useKeyDown(id: string) { canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => { return isHotkey(Keyboard.keys.TAB, e); }, - handler: (e: React.KeyboardEvent<HTMLDivElement>) => { + handler: () => { if (!controller) return; - dispatch( + void dispatch( tabActionForBlockThunk({ id, controller, @@ -73,9 +72,9 @@ export function useKeyDown(id: string) { canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => { return isHotkey(Keyboard.keys.SHIFT_TAB, e); }, - handler: (e: React.KeyboardEvent<HTMLDivElement>) => { + handler: () => { if (!controller) return; - dispatch( + void dispatch( shiftTabActionForBlockThunk({ id, controller, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts index bb4859017e3c..d06f17b1b0a6 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts @@ -6,7 +6,7 @@ import { blockConfig } from '$app/constants/document/config'; import Delta from 'quill-delta'; import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks'; -import { getBlockDelta } from '$app/components/document/_shared/SubscribeNode.hooks'; +import { getBlockDelta } from '$app/components/document/_shared/SubscribeNode.hooks'; import { getDeltaText } from '$app/utils/document/delta'; import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; import { turnIntoConfig } from './shortchut'; @@ -155,7 +155,7 @@ export function useTurnIntoBlockEvents(id: string) { const data = getData(); if (!data) return; - dispatch(turnToBlockThunk({ id, data, type: blockType, controller })); + void dispatch(turnToBlockThunk({ id, data, type: blockType, controller })); }, }; }); @@ -167,9 +167,8 @@ export function useTurnIntoBlockEvents(id: string) { handler: (e: React.KeyboardEvent<HTMLDivElement>) => { e.preventDefault(); if (!controller) return; - const delta = getDeltaContent(); - dispatch( + void dispatch( turnToBlockThunk({ id, controller, @@ -186,7 +185,7 @@ export function useTurnIntoBlockEvents(id: string) { if (!controller) return; const defaultData = blockConfig[BlockType.CodeBlock].defaultData; - dispatch( + void dispatch( turnToBlockThunk({ id, data: { @@ -208,10 +207,11 @@ export function useTurnIntoBlockEvents(id: string) { formula, }; - dispatch(turnToBlockThunk({ id, data, type: BlockType.EquationBlock, controller })); + void dispatch(turnToBlockThunk({ id, data, type: BlockType.EquationBlock, controller })); }, - } + }, ]; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [canHandle, controller, dispatch, getAttrs, getDeltaContent, id, spaceTriggerMap]); return turnIntoBlockEvents; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ToggleListBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ToggleListBlock/index.tsx index 9c3b980bdf0a..54387fe9226c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ToggleListBlock/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/ToggleListBlock/index.tsx @@ -3,13 +3,13 @@ import { BlockType, NestedBlock } from '$app/interfaces/document'; import TextBlock from '$app/components/document/TextBlock'; import NodeChildren from '$app/components/document/Node/NodeChildren'; import { useToggleListBlock } from '$app/components/document/ToggleListBlock/ToggleListBlock.hooks'; -import { IconButton } from '@mui/material'; import { DropDownShowSvg } from '$app/components/_shared/svg/DropDownShowSvg'; import Button from '@mui/material/Button'; function ToggleListBlock({ node, childIds }: { node: NestedBlock<BlockType.ToggleListBlock>; childIds?: string[] }) { const { toggleCollapsed, handleShortcut } = useToggleListBlock(node.id, node.data); const collapsed = node.data.collapsed; + return ( <> <div className={'flex'} onKeyDownCapture={handleShortcut}> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/BlockPopover/BlockPopover.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/BlockPopover/BlockPopover.hooks.tsx index 81c9cd0e4c5c..a86f5e8e9e6e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/BlockPopover/BlockPopover.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/BlockPopover/BlockPopover.hooks.tsx @@ -42,7 +42,7 @@ export function useBlockPopover({ }, [dispatch, docId, id, onAfterClose]); const selectBlock = useCallback(() => { - dispatch( + void dispatch( setRectSelectionThunk({ docId, selection: [id], diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/useCopy.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/useCopy.ts index 2c31f9ab95ce..0a34e4971ca1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/useCopy.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/useCopy.ts @@ -19,7 +19,8 @@ export function useCopy(container: HTMLDivElement) { e.clipboardData?.setData(clipboardTypes.TEXT, data.text); e.clipboardData?.setData(clipboardTypes.HTML, data.html); }; - dispatch( + + void dispatch( copyThunk({ setClipboardData, controller, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/usePaste.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/usePaste.ts index a2cc09d643f8..6ed3c9fd6bbf 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/usePaste.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/usePaste.ts @@ -1,4 +1,4 @@ -import { useCallback, useContext, useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useAppDispatch } from '$app/stores/store'; import { pasteThunk } from '$app_reducers/document/async-actions/copy_paste'; import { clipboardTypes } from '$app/constants/document/copy_paste'; @@ -12,7 +12,7 @@ export function usePaste(container: HTMLDivElement) { if (!controller) return; e.stopPropagation(); e.preventDefault(); - dispatch( + void dispatch( pasteThunk({ controller, data: { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/DatabaseList/index.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/DatabaseList/index.hooks.ts new file mode 100644 index 000000000000..e9af1503128a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/DatabaseList/index.hooks.ts @@ -0,0 +1,26 @@ +import { useAppSelector } from '$app/stores/store'; +import { useEffect, useState } from 'react'; +import { Page } from '$app_reducers/pages/slice'; +import { ViewLayoutPB } from '@/services/backend'; + +export function useLoadDatabaseList({ searchText, layout }: { searchText: string; layout: ViewLayoutPB }) { + const [list, setList] = useState<Page[]>([]); + const pages = useAppSelector((state) => state.pages.pageMap); + + useEffect(() => { + const list = Object.values(pages) + .map((page) => { + return page; + }) + .filter((page) => { + if (page.layout !== layout) return false; + return page.name.toLowerCase().includes(searchText.toLowerCase()); + }); + + setList(list); + }, [layout, pages, searchText]); + + return { + list, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/DatabaseList/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/DatabaseList/index.tsx new file mode 100644 index 000000000000..355ec2216be4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/DatabaseList/index.tsx @@ -0,0 +1,103 @@ +import React, { useMemo } from 'react'; +import { ViewLayoutPB } from '@/services/backend'; +import { useLoadDatabaseList } from '$app/components/document/_shared/DatabaseList/index.hooks'; +import { List } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { BackupTableOutlined } from '@mui/icons-material'; +import MenuItem from '@mui/material/MenuItem'; +import { useAppDispatch } from '@/appflowy_app/stores/store'; +import { turnToBlockThunk } from '$app_reducers/document/async-actions'; +import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; +import { BlockType } from '$app/interfaces/document'; +import AddSvg from '$app/components/_shared/svg/AddSvg'; +import Button from '@mui/material/Button'; +import { PageController } from '$app/stores/effects/workspace/page/page_controller'; + +interface Props { + layout: ViewLayoutPB; + searchText?: string; + blockId: string; + onClose?: () => void; +} + +function DatabaseList({ layout, searchText, blockId, onClose }: Props) { + const { t } = useTranslation(); + const { docId } = useSubscribeDocument(); + const pageController = useMemo(() => new PageController(docId), [docId]); + const dispatch = useAppDispatch(); + const { controller } = useSubscribeDocument(); + const { list } = useLoadDatabaseList({ + searchText: searchText || '', + layout, + }); + + const renderEmpty = () => { + return <div className={'p-2 text-text-caption'}>No {layout === ViewLayoutPB.Grid ? 'grid' : 'list'} found</div>; + }; + + const handleReferenceDatabase = (viewId: string) => { + let blockType; + + switch (layout) { + case ViewLayoutPB.Grid: + blockType = BlockType.GridBlock; + break; + default: + break; + } + + if (blockType === undefined) return; + onClose?.(); + void dispatch( + turnToBlockThunk({ + id: blockId, + controller, + type: blockType, + data: { + viewId, + }, + }) + ); + }; + + const handleCreateNewGrid = async () => { + const newViewId = await pageController.createPage({ + layout, + name: t('editor.table'), + }); + + handleReferenceDatabase(newViewId); + }; + + return ( + <div className={'max-h-[360px] w-[200px] p-3'}> + <div className={'flex items-center justify-center'}> + <Button + color='inherit' + startIcon={ + <i className={'h-8 w-8'}> + <AddSvg /> + </i> + } + onClick={handleCreateNewGrid} + > + {t('document.slashMenu.grid.createANewGrid')} + </Button> + </div> + {list.length === 0 ? ( + renderEmpty() + ) : ( + <List> + {list.map((item) => ( + <MenuItem onClick={() => handleReferenceDatabase(item.id)} key={item.id}> + <div className={'mr-2'}>{item.icon?.value || <BackupTableOutlined />}</div> + {item.name || t('grid.title.placeholder')} + </MenuItem> + ))} + </List> + )} + </div> + ); +} + +export default DatabaseList; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts index 84a95e5bd9bc..e7da44baf886 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts @@ -12,8 +12,6 @@ import { useAppDispatch } from '$app/stores/store'; import { isFormatHotkey, parseFormat } from '$app/utils/document/format'; import { toggleFormatThunk } from '$app_reducers/document/async-actions/format'; import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks'; -import Delta from 'quill-delta'; export function useCommonKeyEvents(id: string) { const { focused, caretRef } = useFocused(id); @@ -35,7 +33,7 @@ export function useCommonKeyEvents(id: string) { handler: (e: React.KeyboardEvent<HTMLDivElement>) => { e.preventDefault(); if (!controller) return; - dispatch(backspaceDeleteActionForBlockThunk({ id, controller })); + void dispatch(backspaceDeleteActionForBlockThunk({ id, controller })); }, }, { @@ -45,7 +43,7 @@ export function useCommonKeyEvents(id: string) { }, handler: (e: React.KeyboardEvent<HTMLDivElement>) => { e.preventDefault(); - dispatch(upDownActionForBlockThunk({ docId, id })); + void dispatch(upDownActionForBlockThunk({ docId, id })); }, }, { @@ -55,7 +53,7 @@ export function useCommonKeyEvents(id: string) { }, handler: (e: React.KeyboardEvent<HTMLDivElement>) => { e.preventDefault(); - dispatch(upDownActionForBlockThunk({ docId, id, down: true })); + void dispatch(upDownActionForBlockThunk({ docId, id, down: true })); }, }, { @@ -66,7 +64,7 @@ export function useCommonKeyEvents(id: string) { handler: (e: React.KeyboardEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); - dispatch(leftActionForBlockThunk({ docId, id })); + void dispatch(leftActionForBlockThunk({ docId, id })); }, }, { @@ -76,7 +74,7 @@ export function useCommonKeyEvents(id: string) { }, handler: (e: React.KeyboardEvent<HTMLDivElement>) => { e.preventDefault(); - dispatch(rightActionForBlockThunk({ docId, id })); + void dispatch(rightActionForBlockThunk({ docId, id })); }, }, { @@ -87,7 +85,7 @@ export function useCommonKeyEvents(id: string) { const format = parseFormat(e); if (!format) return; - dispatch( + void dispatch( toggleFormatThunk({ format, controller, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useDelta.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useDelta.ts index 98baf3012197..9d690d9b0a60 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useDelta.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useDelta.ts @@ -1,5 +1,5 @@ import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { useCallback, useContext, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useAppDispatch } from '$app/stores/store'; import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions'; import Delta, { Op } from 'quill-delta'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts index 92517f57207f..563241fec383 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts @@ -19,8 +19,8 @@ export function useSelection(id: string) { const { docId } = useSubscribeDocument(); const storeRange = useCallback( - (range: RangeStatic) => { - dispatch(storeRangeThunk({ id, range, docId })); + async (range: RangeStatic) => { + await dispatch(storeRangeThunk({ id, range, docId })); }, [docId, id, dispatch] ); @@ -38,7 +38,7 @@ export function useSelection(id: string) { }, }) ); - storeRange(range); + void storeRange(range); }, [docId, id, dispatch, storeRange] ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/CodeInline.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/CodeInline.tsx index 4f2c031602f2..f28892ab22cd 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/CodeInline.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/CodeInline.tsx @@ -1,6 +1,6 @@ import React from 'react'; -function CodeInline({ text, children, selected }: { text: string; children: React.ReactNode; selected: boolean }) { +function CodeInline({ children, selected }: { text: string; children: React.ReactNode; selected: boolean }) { return ( <span className={'bg-content-blue-50 py-1'} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FormulaInline.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FormulaInline.tsx index 6646c7c92ab1..97f4b9e00c81 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FormulaInline.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FormulaInline.tsx @@ -28,12 +28,12 @@ function FormulaInline({ const { docId } = useSubscribeDocument(); const dispatch = useAppDispatch(); const onClick = useCallback( - (node: HTMLSpanElement) => { + async (node: HTMLSpanElement) => { const selection = getSelection(node); if (!selection) return; - dispatch( + await dispatch( createTemporary({ docId, state: { @@ -57,7 +57,7 @@ function FormulaInline({ getSelection={getSelection} isFirst={isFirst} isLast={isLast} - renderNode={() => <KatexMath latex={data.latex!} isInline />} + renderNode={() => <KatexMath latex={data.latex || ''} isInline />} > {children} </FakeCursorContainer> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/LinkInline.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/LinkInline.tsx index 070ec257f077..ebb5c0f4c2c7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/LinkInline.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/LinkInline.tsx @@ -26,17 +26,18 @@ function LinkInline({ const dispatch = useAppDispatch(); const onClick = useCallback( - (e: React.MouseEvent) => { + async (e: React.MouseEvent) => { if (!ref.current) return; const selection = getSelection(ref.current); if (!selection) return; const rect = ref.current?.getBoundingClientRect(); + if (!rect) return; e.stopPropagation(); e.preventDefault(); - dispatch( + await dispatch( createTemporary({ docId, state: { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/PageInline.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/PageInline.tsx index a97726adedf4..6786ba838956 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/PageInline.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/PageInline.tsx @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { pageTypeMap } from '$app/constants'; import { LinearProgress } from '@mui/material'; -import Tooltip from "@mui/material/Tooltip"; +import Tooltip from '@mui/material/Tooltip'; function PageInline({ pageId }: { pageId: string }) { const { t } = useTranslation(); @@ -18,12 +18,14 @@ function PageInline({ pageId }: { pageId: string }) { const controller = new PageController(id); const page = await controller.getPage(); + setCurrentPage(page); }, []); const navigateToPage = useCallback( (page: Page) => { const pageType = pageTypeMap[page.layout]; + navigate(`/page/${pageType}/${page.id}`); }, [navigate] @@ -31,14 +33,12 @@ function PageInline({ pageId }: { pageId: string }) { useEffect(() => { if (!page) { - loadPage(pageId); + void loadPage(pageId); } else { setCurrentPage(page); } - }, [page, loadPage, pageId]); - return currentPage ? ( <Tooltip arrow title={t('document.mention.page.tooltip')} placement={'top'}> <span @@ -49,11 +49,10 @@ function PageInline({ pageId }: { pageId: string }) { }} className={'inline-block cursor-pointer rounded px-1 hover:bg-content-blue-100'} > - <span className={'mr-1'}>{currentPage.icon?.value || <Article />}</span> - <span className={'font-medium underline '}>{currentPage.name || t('menuAppHeader.defaultNewPageName')}</span> - </span> + <span className={'mr-1'}>{currentPage.icon?.value || <Article />}</span> + <span className={'font-medium underline '}>{currentPage.name || t('menuAppHeader.defaultNewPageName')}</span> + </span> </Tooltip> - ) : ( <span> <LinearProgress /> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx index 83bd7fe680b9..6e2e87bb6429 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx @@ -92,6 +92,7 @@ const TextLeaf = (props: TextLeafProps) => { } const mention = leaf.mention; + if (mention && mention.type === MentionType.PAGE && leaf.text) { newChildren = ( <FakeCursorContainer diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts index 6d2da980792a..53d805f3ab99 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts @@ -7,7 +7,6 @@ import { focusNodeByIndex } from '$app/utils/document/node'; import { Keyboard } from '$app/constants/document/keyboard'; import isHotkey from 'is-hotkey'; import { useSlateYjs } from '$app/components/document/_shared/SlateEditor/useSlateYjs'; -import { openMention } from '$app_reducers/document/async-actions/mention'; const AFTER_RENDER_DELAY = 100; @@ -49,6 +48,7 @@ export function useEditor({ : []; const currentSelection = editor.selection || []; let removeMark = markKeys.length > 0; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_, path] = editor.node(currentSelection); if (removeMark) { @@ -113,9 +113,10 @@ export function useEditor({ const decorate = useCallback( (entry: NodeEntry) => { - const [node, path] = entry; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, path] = entry; - const ranges: Range[] = [ + return [ getDecorateRange(path, decorateSelection, { selection_high_lighted: true, }), @@ -123,8 +124,6 @@ export function useEditor({ temporary: true, }), ].filter((range) => range !== null) as Range[]; - - return ranges; }, [temporarySelection, decorateSelection, getDecorateRange] ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts index f377567d5277..ba4669db6cc6 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts @@ -21,6 +21,7 @@ export function useSlateYjs({ delta, onChange }: { delta?: Delta; onChange: (ops // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps const editor = useMemo(() => withYjs(withMarkdown(withReact(createEditor())), sharedType), []); // Connect editor in useEffect to comply with concurrent mode requirements. diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeMention.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeMention.hooks.ts index 17d103a30d6f..2043209b409d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeMention.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeMention.hooks.ts @@ -7,6 +7,7 @@ const initialState: MentionState = { open: false, blockId: '', }; + export function useSubscribeMentionState() { const { docId } = useSubscribeDocument(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts index c687177c6dda..0efefa462137 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts @@ -18,14 +18,17 @@ export function useSubscribeNode(id: string) { }>((state) => { const documentState = state[DOCUMENT_NAME][docId]; const node = documentState?.nodes[id]; + // if node is root, return page name if (!node?.parent) { const delta = state.pages?.pageMap[docId]?.name; + return { node, delta: delta ? JSON.stringify(new Delta().insert(delta)) : '', }; } + const externalId = node?.externalId; return { @@ -51,7 +54,9 @@ export function useSubscribeNode(id: string) { // Memoize the node and its children // So that the component will not be re-rendered when other node is changed // It very important for performance + // eslint-disable-next-line react-hooks/exhaustive-deps const memoizedNode = useMemo(() => node, [JSON.stringify(node)]); + // eslint-disable-next-line react-hooks/exhaustive-deps const memoizedChildIds = useMemo(() => childIds, [JSON.stringify(childIds)]); return { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts index 649d33bc38fe..fa1ee30fd124 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts @@ -2,7 +2,7 @@ import { useAppSelector } from '$app/stores/store'; import { RangeState, RangeStatic } from '$app/interfaces/document'; import { useMemo, useRef } from 'react'; import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { RANGE_NAME, TEMPORARY_NAME, TEXT_LINK_NAME } from '$app/constants/document/name'; +import { RANGE_NAME, TEMPORARY_NAME } from '$app/constants/document/name'; export function useSubscribeDecorate(id: string) { const { docId } = useSubscribeDocument(); @@ -51,7 +51,7 @@ export function useFocused(id: string) { } export function useRangeRef() { - const { docId, controller } = useSubscribeDocument(); + const { docId } = useSubscribeDocument(); const rangeRef = useRef<RangeState>(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryLink.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryLink.tsx index 117f787b1a38..f16352a31be8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryLink.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryLink.tsx @@ -2,8 +2,9 @@ import React from 'react'; import { AddLinkOutlined } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; -function TemporaryLink({ href = '', text = '' }: { href?: string; text?: string }) { +function TemporaryLink({ text = '' }: { href?: string; text?: string }) { const { t } = useTranslation(); + return ( <span className={'bg-content-blue-100'} contentEditable={false}> {text ? ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryPopover.tsx index 6f27a871bf15..ec53767774b9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryPopover.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryPopover.tsx @@ -17,7 +17,6 @@ function TemporaryPopover() { const anchorPosition = useMemo(() => temporaryState?.popoverPosition, [temporaryState]); const open = Boolean(anchorPosition); const id = temporaryState?.id; - const type = temporaryState?.type; const dispatch = useAppDispatch(); const { docId, controller } = useSubscribeDocument(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/index.tsx index 4afe2ae2c651..4e9460dbf3d1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/index.tsx @@ -77,6 +77,7 @@ function TemporaryInput({ useEffect(() => { const match = getMatch(); + setMatch(match); }, [getMatch]); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/TurnInto.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/TurnInto.hooks.ts index edbda073334d..1f1df71304b1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/TurnInto.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/TurnInto.hooks.ts @@ -12,7 +12,7 @@ export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: () const { controller, docId } = useSubscribeDocument(); const turnIntoBlock = useCallback( - async (type: BlockType, isSelected: boolean, data?: BlockData<any>) => { + async (type: BlockType, isSelected: boolean, data?: BlockData) => { if (!controller || isSelected) { onClose?.(); return; @@ -35,7 +35,7 @@ export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: () ); onClose?.(); - dispatch( + await dispatch( setRectSelectionThunk({ docId, selection: [newBlockId as string], diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/index.tsx index eb3661fb5119..b1388ff6ce40 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/index.tsx @@ -57,7 +57,7 @@ const TurnIntoPopover = ({ icon: <Title />, selected: node?.data?.level === 1, onClick: (type: BlockType, isSelected: boolean) => { - turnIntoHeading(1, isSelected); + void turnIntoHeading(1, isSelected); }, }, { @@ -67,7 +67,7 @@ const TurnIntoPopover = ({ icon: <Title />, selected: node?.data?.level === 2, onClick: (type: BlockType, isSelected: boolean) => { - turnIntoHeading(2, isSelected); + void turnIntoHeading(2, isSelected); }, }, { @@ -77,7 +77,7 @@ const TurnIntoPopover = ({ icon: <Title />, selected: node?.data?.level === 3, onClick: (type: BlockType, isSelected: boolean) => { - turnIntoHeading(3, isSelected); + void turnIntoHeading(3, isSelected); }, }, { @@ -143,7 +143,7 @@ const TurnIntoPopover = ({ (option: Option) => { const isSelected = getSelected(option); - option.onClick ? option.onClick(option.type, isSelected) : turnIntoBlock(option.type, isSelected); + option.onClick ? option.onClick(option.type, isSelected) : void turnIntoBlock(option.type, isSelected); onOk?.(); }, [onOk, getSelected, turnIntoBlock] diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UndoHooks/useUndoRedo.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UndoHooks/useUndoRedo.ts index f9f35799637c..3a101cac22b1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UndoHooks/useUndoRedo.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UndoHooks/useUndoRedo.ts @@ -6,25 +6,26 @@ import { useSubscribeDocument } from '$app/components/document/_shared/Subscribe export function useUndoRedo(container: HTMLDivElement) { const { controller } = useSubscribeDocument(); - const onUndo = useCallback(() => { + const onUndo = useCallback(async () => { if (!controller) return; - controller.undo(); + await controller.undo(); }, [controller]); - const onRedo = useCallback(() => { + const onRedo = useCallback(async () => { if (!controller) return; - controller.redo(); + await controller.redo(); }, [controller]); const handleKeyDownCapture = useCallback( - (e: KeyboardEvent) => { + async (e: KeyboardEvent) => { if (isHotkey(Keyboard.keys.UNDO, e)) { e.stopPropagation(); - onUndo(); + await onUndo(); } + if (isHotkey(Keyboard.keys.REDO, e)) { e.stopPropagation(); - onRedo(); + await onRedo(); } }, [onRedo, onUndo] diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/ImageEditPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/ImageEditPopover.tsx index af31ae70c503..e341563d579d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/ImageEditPopover.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/ImageEditPopover.tsx @@ -1,7 +1,6 @@ import React from 'react'; import Popover, { PopoverProps } from '@mui/material/Popover'; import ImageEdit from './ImageEdit'; -import { PopoverOrigin } from '@mui/material/Popover/Popover'; interface Props extends PopoverProps { onSubmitUrl: (url: string) => void; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/TabPanel.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/TabPanel.tsx index 1f0f6ab4ef4c..9356a246aa5f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/TabPanel.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/TabPanel.tsx @@ -1,4 +1,5 @@ import React from 'react'; + export enum TAB_KEYS { UPLOAD = 'upload', LINK = 'link', diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/UploadImage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/UploadImage.tsx index ecb14d7d2f8d..5990f5b5b959 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/UploadImage.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/UploadImage.tsx @@ -42,6 +42,7 @@ function UploadImage({ onChange }: UploadImageProps) { duration: 3000, type: 'error', }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [error]); const handleUpload = useCallback( @@ -74,7 +75,7 @@ function UploadImage({ onChange }: UploadImageProps) { if (!files || files.length === 0) return; const file = files[0]; - handleUpload(file); + void handleUpload(file); }, [handleUpload] ); @@ -87,7 +88,7 @@ function UploadImage({ onChange }: UploadImageProps) { if (!files || files.length === 0) return; const file = files[0]; - handleUpload(file); + void handleUpload(file); }, [handleUpload] ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/useBindArrowKey.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/useBindArrowKey.ts index 8d2ac27796c1..29aebe52e909 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/useBindArrowKey.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/useBindArrowKey.ts @@ -83,6 +83,7 @@ export const useBindArrowKey = ({ } else { document.removeEventListener('keydown', handleArrowKey, true); } + return () => { document.removeEventListener('keydown', handleArrowKey, true); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/usePanelSearchText.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/usePanelSearchText.ts index 245041f702b9..034919e83875 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/usePanelSearchText.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/usePanelSearchText.ts @@ -25,9 +25,11 @@ export function useSubscribePanelSearchText({ blockId, open }: { blockId: string beforeOpenDeltaRef.current = []; return; } + if (beforeOpenDeltaRef.current.length > 0) return; const delta = new Delta(JSON.parse(deltaStr || "{}")); + beforeOpenDeltaRef.current = delta.ops; handleSearch(delta); }, [deltaStr, handleSearch, open]); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/error/Error.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/error/Error.hooks.ts index 2da03c5f5ccf..ceaa5a51a00a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/error/Error.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/error/Error.hooks.ts @@ -1,6 +1,6 @@ -import { useAppDispatch, useAppSelector } from '../../stores/store'; -import { errorActions } from '../../stores/reducers/error/slice'; -import { useEffect, useState } from 'react'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { errorActions } from '$app_reducers/error/slice'; +import { useCallback, useEffect, useState } from 'react'; export const useError = (e: Error) => { const dispatch = useAppDispatch(); @@ -13,15 +13,18 @@ export const useError = (e: Error) => { setErrorMessage(error.message); }, [error]); + const showError = useCallback( + (msg: string) => { + dispatch(errorActions.showError(msg)); + }, + [dispatch] + ); + useEffect(() => { if (e) { showError(e.message); } - }, [e]); - - const showError = (msg: string) => { - dispatch(errorActions.showError(msg)); - }; + }, [e, showError]); const hideError = () => { dispatch(errorActions.hideError()); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableCount/GridTableCount.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableCount/GridTableCount.tsx index 727f831a8166..51ffd11186ca 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableCount/GridTableCount.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableCount/GridTableCount.tsx @@ -2,6 +2,7 @@ import { RowInfo } from '@/appflowy_app/stores/effects/database/row/row_cache'; export const GridTableCount = ({ rows }: { rows: readonly RowInfo[] }) => { const count = rows.length; + return ( <span> Count : <span className='font-semibold'>{count}</span> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableHeader/GridTableHeaderItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableHeader/GridTableHeaderItem.tsx index c4b85be45b4c..194709d40cf9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableHeader/GridTableHeaderItem.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableHeader/GridTableHeaderItem.tsx @@ -53,7 +53,8 @@ export const GridTableHeaderItem = ({ if (newSizeX >= MIN_COLUMN_WIDTH) { dispatch(databaseActions.changeWidth({ fieldId: field.fieldId, width: newSizeX })); } - }, [newSizeX]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [newSizeX, dispatch]); const changeFieldType = async (newType: FieldType) => { if (!editingField) return; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/Breadcrumb.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/Breadcrumb.hooks.ts index 2288657d7b8a..7063cea877e1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/Breadcrumb.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/Breadcrumb.hooks.ts @@ -1,9 +1,9 @@ -import { useAppSelector } from "$app/stores/store"; +import { useAppSelector } from '$app/stores/store'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useParams, useLocation } from 'react-router-dom'; import { Page } from '$app_reducers/pages/slice'; import { useTranslation } from 'react-i18next'; -import { PageController } from "$app/stores/effects/workspace/page/page_controller"; +import { PageController } from '$app/stores/effects/workspace/page/page_controller'; export function useLoadExpandedPages() { const { t } = useTranslation(); @@ -25,6 +25,7 @@ export function useLoadExpandedPages() { async (pageId: string) => { let page = pageMap[pageId]; const controller = new PageController(pageId); + if (!page) { try { page = await controller.getPage(); @@ -36,22 +37,22 @@ export function useLoadExpandedPages() { return; } } - setPagePath(prev => { - return [ - page, - ...prev - ] + + setPagePath((prev) => { + return [page, ...prev]; }); await loadPagePath(page.parentId); - - }, [pageMap]); + }, + [pageMap] + ); useEffect(() => { setPagePath([]); if (!currentPageId) { return; } - loadPagePath(currentPageId); + + void loadPagePath(currentPageId); // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentPageId]); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPage.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPage.hooks.ts index 7acd56508752..428c77193b8c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPage.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPage.hooks.ts @@ -36,30 +36,30 @@ export function useLoadChildPages(pageId: string) { [dispatch] ); + const loadPageChildren = useCallback( + async (pageId: string) => { + const childPages = await controller.getChildPages(); - const loadPageChildren = useCallback(async (pageId: string) => { - const childPages = await controller.getChildPages(); - - dispatch( - pagesActions.addChildPages({ - id: pageId, - childPages, - }) - ); - - }, [controller, dispatch]); - + dispatch( + pagesActions.addChildPages({ + id: pageId, + childPages, + }) + ); + }, + [controller, dispatch] + ); useEffect(() => { void loadPageChildren(pageId); }, [loadPageChildren, pageId]); useEffect(() => { - controller.subscribe({ + void controller.subscribe({ onPageChanged, }); return () => { - controller.dispose(); + void controller.dispose(); }; }, [controller, onPageChanged]); @@ -88,7 +88,7 @@ export function usePageActions(pageId: string) { async (layout: ViewLayoutPB) => { const newViewId = await controller.createPage({ layout, - name: "" + name: '', }); dispatch(pagesActions.expandPage(pageId)); @@ -116,7 +116,7 @@ export function usePageActions(pageId: string) { useEffect(() => { return () => { - controller.dispose(); + void controller.dispose(); }; }, [controller]); @@ -134,4 +134,3 @@ export function useSelectedPage(pageId: string) { return id === pageId; } - diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Share/Share.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Share/Share.hooks.ts index 8cce47a2abd8..b281706848d0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Share/Share.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Share/Share.hooks.ts @@ -1,4 +1,4 @@ -import { useLocation, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; export function useShareConfig() { const params = useParams(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreButton.tsx index bf2d48d8273d..ca104218ab70 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreButton.tsx @@ -1,8 +1,7 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Drawer, IconButton } from '@mui/material'; import { Details2Svg } from '$app/components/_shared/svg/Details2Svg'; -import { LogoutOutlined } from '@mui/icons-material'; import Tooltip from '@mui/material/Tooltip'; import MoreOptions from '$app/components/layout/TopBar/MoreOptions'; import { useMoreOptionsConfig } from '$app/components/layout/TopBar/MoreOptions.hooks'; @@ -19,7 +18,7 @@ function MoreButton() { return ( <> <Tooltip placement={'bottom-end'} title={t('moreAction.moreOptions')}> - <IconButton onClick={(e) => toggleDrawer(true)} className={'h-8 w-8 text-icon-primary'}> + <IconButton onClick={() => toggleDrawer(true)} className={'h-8 w-8 text-icon-primary'}> <Details2Svg /> </IconButton> </Tooltip> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreOptions.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreOptions.hooks.ts index 57b10a4fede3..63f1173885ed 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreOptions.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreOptions.hooks.ts @@ -4,7 +4,8 @@ import { useMemo } from 'react'; export function useMoreOptionsConfig() { const location = useLocation(); - const { type, pageType, id } = useMemo(() => { + const { type, pageType } = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_, type, pageType, id] = location.pathname.split('/'); return { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreOptions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreOptions.tsx index 97a48128f7f4..28f2b467599a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreOptions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreOptions.tsx @@ -1,12 +1,8 @@ import React from 'react'; -import { useTranslation } from 'react-i18next'; import FontSizeConfig from '$app/components/layout/TopBar/FontSizeConfig'; -import { Divider } from '@mui/material'; -import { useLocation } from 'react-router-dom'; import { useMoreOptionsConfig } from '$app/components/layout/TopBar/MoreOptions.hooks'; function MoreOptions() { - const { t } = useTranslation(); const { showStyleOptions } = useMoreOptionsConfig(); return <div className={'flex w-[220px] flex-col'}>{showStyleOptions && <FontSizeConfig />}</div>; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/LanguageSetting.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/LanguageSetting.tsx index b2395d7645c4..bcd171f4c011 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/LanguageSetting.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/LanguageSetting.tsx @@ -56,7 +56,7 @@ function LanguageSetting({ onChange({ language, }); - i18n.changeLanguage(language); + void i18n.changeLanguage(language); }} > {languages.map((option) => ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/index.tsx index 5b4ec98294af..9fa5beabcfcb 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/index.tsx @@ -30,7 +30,7 @@ function UserSettings({ open, onClose }: { open: boolean; onClose: () => void }) if (userSettingController) { const language = newSetting.language || 'en'; - userSettingController.setAppearanceSetting({ + void userSettingController.setAppearanceSetting({ theme: newSetting.theme || Theme.Default, theme_mode: newSetting.themeMode || ThemeModePB.Light, locale: { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NewPageButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NewPageButton.tsx index e2346fa7a9f5..ad405024a14d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NewPageButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NewPageButton.tsx @@ -12,7 +12,7 @@ function NewPageButton({ workspaceId }: { workspaceId: string }) { useEffect(() => { return () => { - controller.dispose(); + void controller.dispose(); }; }, [controller]); @@ -21,7 +21,7 @@ function NewPageButton({ workspaceId }: { workspaceId: string }) { <button onClick={async () => { const { id } = await controller.createView({ - name: "", + name: '', layout: ViewLayoutPB.Document, parent_view_id: workspaceId, }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.hooks.ts index a6e00bbbfe2e..e04bd734a014 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.hooks.ts @@ -45,7 +45,7 @@ export function useLoadWorkspaces() { })(); return () => { - controller.dispose(); + void controller.dispose(); }; }, [controller, initializeWorkspaces, subscribeToWorkspaces]); @@ -85,6 +85,7 @@ export function useLoadWorkspace(workspace: WorkspaceItem) { const initializeWorkspace = useCallback(async () => { const childPages = await controller.getChildPages(); + dispatch( pagesActions.addChildPages({ id, @@ -106,7 +107,7 @@ export function useLoadWorkspace(workspace: WorkspaceItem) { })(); return () => { - controller.dispose(); + void controller.dispose(); }; }, [controller, initializeWorkspace, subscribeToWorkspace]); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.tsx index bafea58b3add..b80b8c28e5d9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.tsx @@ -2,11 +2,9 @@ import React from 'react'; import { WorkspaceItem } from '$app_reducers/workspace/slice'; import NestedViews from '$app/components/layout/WorkspaceManager/NestedPages'; import { useLoadWorkspace } from '$app/components/layout/WorkspaceManager/Workspace.hooks'; -import WorkspaceTitle from '$app/components/layout/WorkspaceManager/WorkspaceTitle'; function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: boolean }) { - const { openWorkspace, deleteWorkspace } = useLoadWorkspace(workspace); - + useLoadWorkspace(workspace); return ( <div className={'flex h-[100%] flex-col'}> <div diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/tests/DatabaseTestHelper.ts b/frontend/appflowy_tauri/src/appflowy_app/components/tests/DatabaseTestHelper.ts index 9b4e90b314f6..eb5ea8a9094f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/tests/DatabaseTestHelper.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/tests/DatabaseTestHelper.ts @@ -1,42 +1,34 @@ -import { - FieldType, - FlowyError, - SingleSelectTypeOptionPB, - ViewLayoutPB, - ViewPB, - WorkspaceSettingPB, -} from '../../../services/backend'; -import { FolderEventGetCurrentWorkspace } from '../../../services/backend/events/flowy-folder2'; -import { DatabaseController } from '../../stores/effects/database/database_controller'; -import { RowInfo } from '../../stores/effects/database/row/row_cache'; -import { RowController } from '../../stores/effects/database/row/row_controller'; +import { FieldType, SingleSelectTypeOptionPB, ViewLayoutPB, ViewPB, WorkspaceSettingPB } from '@/services/backend'; +import { DatabaseController } from '$app/stores/effects/database/database_controller'; +import { RowInfo } from '$app/stores/effects/database/row/row_cache'; +import { RowController } from '$app/stores/effects/database/row/row_controller'; import { CellControllerBuilder, CheckboxCellController, DateCellController, - NumberCellController, SelectOptionCellController, TextCellController, URLCellController, -} from '../../stores/effects/database/cell/controller_builder'; -import { None, Ok, Option, Result, Some } from 'ts-results'; -import { TypeOptionBackendService } from '../../stores/effects/database/field/type_option/type_option_bd_svc'; -import { DatabaseBackendService } from '../../stores/effects/database/database_bd_svc'; -import { FieldInfo } from '../../stores/effects/database/field/field_controller'; -import { TypeOptionController } from '../../stores/effects/database/field/type_option/type_option_controller'; -import { makeSingleSelectTypeOptionContext } from '../../stores/effects/database/field/type_option/type_option_context'; -import { SelectOptionBackendService } from '../../stores/effects/database/cell/select_option_bd_svc'; -import { Log } from '$app/utils/log'; -import { WorkspaceController } from '../../stores/effects/workspace/workspace_controller'; +} from '$app/stores/effects/database/cell/controller_builder'; +import { None, Option, Some } from 'ts-results'; +import { TypeOptionBackendService } from '$app/stores/effects/database/field/type_option/type_option_bd_svc'; +import { DatabaseBackendService } from '$app/stores/effects/database/database_bd_svc'; +import { FieldInfo } from '$app/stores/effects/database/field/field_controller'; +import { TypeOptionController } from '$app/stores/effects/database/field/type_option/type_option_controller'; +import { makeSingleSelectTypeOptionContext } from '$app/stores/effects/database/field/type_option/type_option_context'; +import { SelectOptionBackendService } from '$app/stores/effects/database/cell/select_option_bd_svc'; +import { WorkspaceController } from '$app/stores/effects/workspace/workspace_controller'; +import { FolderEventGetCurrentWorkspaceSetting } from '@/services/backend/events/flowy-folder2'; // Create a database page for specific layout type // Do not use it production code. Just for testing export async function createTestDatabaseView(layout: ViewLayoutPB): Promise<ViewPB> { - const workspaceSetting: WorkspaceSettingPB = await FolderEventGetCurrentWorkspace().then((result) => result.unwrap()); - const wsSvc = new WorkspaceController(workspaceSetting.workspace.id); - const viewRes = await wsSvc.createView({ name: 'New Grid', layout }); + const workspaceSetting: WorkspaceSettingPB = await FolderEventGetCurrentWorkspaceSetting().then((result) => + result.unwrap() + ); + const wsSvc = new WorkspaceController(workspaceSetting.workspace_id); - return viewRes; + return await wsSvc.createView({ name: 'New Grid', layout }); } export async function openTestDatabase(viewId: string): Promise<DatabaseController> { @@ -90,18 +82,6 @@ export async function makeTextCellController( return Some(builder.build() as TextCellController); } -export async function makeNumberCellController( - fieldId: string, - rowInfo: RowInfo, - databaseController: DatabaseController -): Promise<Option<NumberCellController>> { - const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.Number, databaseController).then( - (result) => result.unwrap() - ); - - return Some(builder.build() as NumberCellController); -} - export async function makeSingleSelectCellController( fieldId: string, rowInfo: RowInfo, @@ -165,7 +145,7 @@ export async function makeURLCellController( export async function makeCellControllerBuilder( fieldId: string, rowInfo: RowInfo, - fieldType: FieldType, + _fieldType: FieldType, databaseController: DatabaseController ): Promise<Option<CellControllerBuilder>> { const rowCache = databaseController.databaseViewCache.getRowCache(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/tests/DocumentTestHelper.ts b/frontend/appflowy_tauri/src/appflowy_app/components/tests/DocumentTestHelper.ts index a0ff41e929f3..c9d1f0af4da4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/tests/DocumentTestHelper.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/tests/DocumentTestHelper.ts @@ -1,10 +1,12 @@ import { ViewLayoutPB, WorkspaceSettingPB } from '@/services/backend'; -import { FolderEventGetCurrentWorkspace } from '@/services/backend/events/flowy-folder2'; import { WorkspaceController } from '../../stores/effects/workspace/workspace_controller'; +import { FolderEventGetCurrentWorkspaceSetting } from '@/services/backend/events/flowy-folder2'; export async function createTestDocument() { - const workspaceSetting: WorkspaceSettingPB = await FolderEventGetCurrentWorkspace().then((result) => result.unwrap()); - const appService = new WorkspaceController(workspaceSetting.workspace.id); + const workspaceSetting: WorkspaceSettingPB = await FolderEventGetCurrentWorkspaceSetting().then((result) => + result.unwrap() + ); + const appService = new WorkspaceController(workspaceSetting.workspace_id); const result = await appService.createView({ name: 'New Document', layout: ViewLayoutPB.Document }); return result; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestDocument.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestDocument.tsx index 8508232eb954..b6f70acb02da 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestDocument.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestDocument.tsx @@ -5,7 +5,8 @@ import { DocumentBackendService } from '../../stores/effects/document/document_b async function testCreateDocument() { const view = await createTestDocument(); const svc = new DocumentBackendService(view.id); - const document = await svc.open().then((result) => result.unwrap()); + + await svc.open().then((result) => result.unwrap()); // eslint-disable-next-line @typescript-eslint/no-unused-vars // const content = JSON.parse(document.content); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestFolder.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestFolder.tsx index 1989bcdd4c2f..6e24aae8a129 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestFolder.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestFolder.tsx @@ -18,9 +18,8 @@ const testCreateFolder = async (userId?: number) => { console.log('workspaces: ', workspaces.val.toObject()); } - const currentWorkspace = await userBackendService.getCurrentWorkspace(); - - const workspaceService = new WorkspaceController(currentWorkspace.workspace.id); + const currentWorkspaceSetting = await userBackendService.getCurrentWorkspaceSetting(); + const workspaceService = new WorkspaceController(currentWorkspaceSetting.workspace_id); const rootViews: ViewPB[] = []; for (let i = 1; i <= 3; i++) { @@ -34,7 +33,7 @@ const testCreateFolder = async (userId?: number) => { } for (let i = 1; i <= 3; i++) { - const result = await workspaceService.createView({ + await workspaceService.createView({ name: `test board 1 ${i}`, desc: 'test description', layout: ViewLayoutPB.Board, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestFonts.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestFonts.tsx index c2c5d5d13c2c..04e4c566a665 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestFonts.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestFonts.tsx @@ -1,9 +1,9 @@ -import { useState } from 'react'; +import { ChangeEvent, useState } from 'react'; const TestFonts = () => { const [sampleText, setSampleText] = useState('Sample Text'); - const onInputChange = (e: any) => { + const onInputChange = (e: ChangeEvent<HTMLInputElement>) => { setSampleText(e.target.value); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestGrid.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestGrid.tsx index d4345b6760de..e9e687a923f9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestGrid.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestGrid.tsx @@ -65,6 +65,7 @@ export const RunAllGridTests = () => { async function createBuildInGrid() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + databaseController.subscribe({ onViewChanged: (databasePB) => { Log.debug('Did receive database:' + databasePB); @@ -87,11 +88,13 @@ async function createBuildInGrid() { async function testEditGridCell() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); for (const [index, row] of databaseController.databaseViewCache.rowInfos.entries()) { const cellContent = index.toString(); const fieldInfo = findFirstFieldInfoWithFieldType(row, FieldType.RichText).unwrap(); + await editTextCell(fieldInfo.field.id, row, databaseController, cellContent); await assertTextCell(fieldInfo.field.id, row, databaseController, cellContent); } @@ -100,6 +103,7 @@ async function testEditGridCell() { async function testEditTextCell() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); const row = databaseController.databaseViewCache.rowInfos[0]; @@ -122,9 +126,11 @@ async function testEditTextCell() { async function testEditURLCell() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); const typeOptionController = new TypeOptionController(view.id, None, FieldType.URL); + await typeOptionController.initialize(); const row = databaseController.databaseViewCache.rowInfos[0]; @@ -135,6 +141,7 @@ async function testEditURLCell() { urlCellController.subscribeChanged({ onCellChanged: (content) => { const pb = content.unwrap(); + Log.info('Receive url data:', pb.url, pb.content); }, }); @@ -149,9 +156,11 @@ async function testEditURLCell() { async function testEditDateCell() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); const typeOptionController = new TypeOptionController(view.id, None, FieldType.DateTime); + await typeOptionController.initialize(); const row = databaseController.databaseViewCache.rowInfos[0]; @@ -162,11 +171,13 @@ async function testEditDateCell() { dateCellController.subscribeChanged({ onCellChanged: (content) => { const pb = content.unwrap(); + Log.info('Receive date data:', pb.date, pb.time); }, }); const date = new CalendarData(new Date(), true, '13:00'); + await dateCellController.saveCellData(date); await new Promise((resolve) => setTimeout(resolve, 200)); } @@ -174,15 +185,18 @@ async function testEditDateCell() { async function testEditDateFormatPB() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); // Create date field const typeOptionController = new TypeOptionController(view.id, None, FieldType.DateTime); + await typeOptionController.initialize(); // update date type option const dateTypeOptionContext = makeDateTypeOptionContext(typeOptionController); const typeOption = await dateTypeOptionContext.getTypeOption().then((a) => a.unwrap()); + assert(typeOption.date_format === DateFormatPB.Friendly, 'Date format not match'); assert(typeOption.time_format === TimeFormatPB.TwentyFourHour, 'Time format not match'); typeOption.date_format = DateFormatPB.Local; @@ -190,6 +204,7 @@ async function testEditDateFormatPB() { await dateTypeOptionContext.setTypeOption(typeOption); const typeOption2 = await dateTypeOptionContext.getTypeOption().then((a) => a.unwrap()); + assert(typeOption2.date_format === DateFormatPB.Local, 'Date format not match'); assert(typeOption2.time_format === TimeFormatPB.TwelveHour, 'Time format not match'); @@ -199,20 +214,24 @@ async function testEditDateFormatPB() { async function testEditNumberFormatPB() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); // Create date field const typeOptionController = new TypeOptionController(view.id, None, FieldType.Number); + await typeOptionController.initialize(); // update date type option const dateTypeOptionContext = makeNumberTypeOptionContext(typeOptionController); const typeOption = await dateTypeOptionContext.getTypeOption().then((a) => a.unwrap()); + typeOption.format = NumberFormatPB.EUR; typeOption.name = 'Money'; await dateTypeOptionContext.setTypeOption(typeOption); const typeOption2 = await dateTypeOptionContext.getTypeOption().then((a) => a.unwrap()); + Log.info(typeOption2); await new Promise((resolve) => setTimeout(resolve, 200)); } @@ -220,9 +239,11 @@ async function testEditNumberFormatPB() { async function testCheckboxCell() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); const typeOptionController = new TypeOptionController(view.id, None, FieldType.Checkbox); + await typeOptionController.initialize(); const row = databaseController.databaseViewCache.rowInfos[0]; @@ -235,6 +256,7 @@ async function testCheckboxCell() { checkboxCellController.subscribeChanged({ onCellChanged: (content) => { const pb = content.unwrap(); + Log.info('Receive checkbox data:', pb); }, }); @@ -246,6 +268,7 @@ async function testCheckboxCell() { async function testCreateRow() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); await assertNumberOfRows(view.id, 3); @@ -258,9 +281,11 @@ async function testCreateRow() { async function testDeleteRow() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); const rows = databaseController.databaseViewCache.rowInfos; + await databaseController.deleteRow(rows[0].row.id); await assertNumberOfRows(view.id, 2); @@ -270,12 +295,14 @@ async function testDeleteRow() { if (databaseController.databaseViewCache.rowInfos.length !== 2) { throw Error('The number of rows is not match'); } + await databaseController.dispose(); } async function testCreateOptionInCell() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); for (const [index, row] of databaseController.databaseViewCache.rowInfos.entries()) { if (index === 0) { @@ -283,39 +310,47 @@ async function testCreateOptionInCell() { const cellController = await makeSingleSelectCellController(fieldInfo.field.id, row, databaseController).then( (result) => result.unwrap() ); + // eslint-disable-next-line @typescript-eslint/await-thenable await cellController.subscribeChanged({ onCellChanged: (value) => { if (value.some) { const option: SelectOptionCellDataPB = value.unwrap(); + console.log(option); } }, }); const backendSvc = new SelectOptionCellBackendService(cellController.cellIdentifier); + await backendSvc.createOption({ name: 'option' + index }); await cellController.dispose(); } } + await databaseController.dispose(); } async function testMoveField() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); const ids = databaseController.fieldController.fieldInfos.map((value) => value.field.id); + Log.info('Receive fields data:', ids); databaseController.subscribe({ onFieldsChanged: (values) => { const new_ids = values.map((value) => value.field.id); + Log.info('Receive fields data:', new_ids); }, }); const fieldInfos = [...databaseController.fieldController.fieldInfos]; const field_id = fieldInfos[0].field.id; + await databaseController.moveField({ fieldId: field_id, fromIndex: 0, toIndex: 1 }); await new Promise((resolve) => setTimeout(resolve, 200)); assert(databaseController.fieldController.fieldInfos[1].field.id === field_id); @@ -324,6 +359,7 @@ async function testMoveField() { async function testGetSingleSelectFieldData() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); // Find the single select column @@ -341,6 +377,7 @@ async function testGetSingleSelectFieldData() { // Read options const options = await singleSelectTypeOptionContext.getTypeOption().then((result) => result.unwrap()); + console.log(options); await databaseController.dispose(); @@ -349,6 +386,7 @@ async function testGetSingleSelectFieldData() { async function testSwitchFromSingleSelectToNumber() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); // Find the single select column @@ -357,6 +395,7 @@ async function testSwitchFromSingleSelectToNumber() { (fieldInfo) => fieldInfo.field.field_type === FieldType.SingleSelect )!; const typeOptionController = new TypeOptionController(view.id, Some(singleSelect)); + await typeOptionController.switchToField(FieldType.Number); // Check the number type option @@ -365,6 +404,7 @@ async function testSwitchFromSingleSelectToNumber() { .getTypeOption() .then((result) => result.unwrap()); const format: NumberFormatPB = numberTypeOption.format; + if (format !== NumberFormatPB.Num) { throw Error('The default format should be number'); } @@ -375,10 +415,12 @@ async function testSwitchFromSingleSelectToNumber() { async function testSwitchFromMultiSelectToRichText() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); // Create multi-select field const typeOptionController = new TypeOptionController(view.id, None, FieldType.MultiSelect); + await typeOptionController.initialize(); // Insert options to first row @@ -391,11 +433,13 @@ async function testSwitchFromMultiSelectToRichText() { databaseController ).then((result) => result.unwrap()); const backendSvc = new SelectOptionCellBackendService(selectOptionCellController.cellIdentifier); + await backendSvc.createOption({ name: 'A' }); await backendSvc.createOption({ name: 'B' }); await backendSvc.createOption({ name: 'C' }); const selectOptionCellData = await selectOptionCellController.getCellData().then((result) => result.unwrap()); + if (selectOptionCellData.options.length !== 3) { throw Error('The options should equal to 3'); } @@ -403,6 +447,7 @@ async function testSwitchFromMultiSelectToRichText() { if (selectOptionCellData.select_options.length !== 3) { throw Error('The selected options should equal to 3'); } + await selectOptionCellController.dispose(); // Switch to RichText field type @@ -415,6 +460,7 @@ async function testSwitchFromMultiSelectToRichText() { (result) => result.unwrap() ); const cellContent = await textCellController.getCellData(); + if (cellContent.unwrap() !== 'A,B,C') { throw Error('The cell content should be A,B,C, but receive: ' + cellContent.unwrap()); } @@ -425,14 +471,17 @@ async function testSwitchFromMultiSelectToRichText() { async function testEditField() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); const fieldInfos = databaseController.fieldController.fieldInfos; // Modify the name of the field const firstFieldInfo = fieldInfos[0]; const controller = new TypeOptionController(view.id, Some(firstFieldInfo)); + await controller.initialize(); const newName = 'hello world'; + await controller.setFieldName(newName); await new Promise((resolve) => setTimeout(resolve, 200)); @@ -443,11 +492,13 @@ async function testEditField() { async function testCreateNewField() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); await assertNumberOfFields(view.id, 3); // Modify the name of the field const controller = new TypeOptionController(view.id, None); + await controller.initialize(); await assertNumberOfFields(view.id, 4); await databaseController.dispose(); @@ -456,6 +507,7 @@ async function testCreateNewField() { async function testDeleteField() { const view = await createTestDatabaseView(ViewLayoutPB.Grid); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); // Modify the name of the field. @@ -463,6 +515,7 @@ async function testDeleteField() { // So let choose the second fieldInfo. const fieldInfo = databaseController.fieldController.fieldInfos[1]; const controller = new TypeOptionController(view.id, Some(fieldInfo)); + await controller.initialize(); await assertNumberOfFields(view.id, 3); await controller.deleteField(); @@ -485,24 +538,31 @@ export const TestEditTextCell = () => { export const TestEditURLCell = () => { return TestButton('Test editing URL cell', testEditURLCell); }; + export const TestEditDateCell = () => { return TestButton('Test editing date cell', testEditDateCell); }; + export const TestEditDateFormat = () => { return TestButton('Test editing date format', testEditDateFormatPB); }; + export const TestEditNumberFormat = () => { return TestButton('Test editing number format', testEditNumberFormatPB); }; + export const TestEditCheckboxCell = () => { return TestButton('Test editing checkbox cell', testCheckboxCell); }; + export const TestCreateRow = () => { return TestButton('Test create row', testCreateRow); }; + export const TestDeleteRow = () => { return TestButton('Test delete row', testDeleteRow); }; + export const TestCreateSelectOptionInCell = () => { return TestButton('Test create a select option in cell', testCreateOptionInCell); }; @@ -522,6 +582,7 @@ export const TestSwitchFromMultiSelectToText = () => { export const TestMoveField = () => { return TestButton('Test move field', testMoveField); }; + export const TestEditField = () => { return TestButton('Test edit the column name', testEditField); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestGroup.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestGroup.tsx index 31ea758924ed..c161eeeeb1bc 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestGroup.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestGroup.tsx @@ -31,6 +31,7 @@ export const TestAllKanbanTests = () => { async function createBuildInBoard() { const view = await createTestDatabaseView(ViewLayoutPB.Board); const databaseController = await openTestDatabase(view.id); + databaseController.subscribe({ onGroupByField: (groups) => { console.log(groups); @@ -51,10 +52,12 @@ async function createBuildInBoard() { async function createKanbanBoardRow() { const view = await createTestDatabaseView(ViewLayoutPB.Board); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); // Create row in no status group const noStatusGroup = databaseController.groups.getValue()[0]; + await noStatusGroup.createRow().then((result) => result.unwrap()); await assertNumberOfRowsInGroup(view.id, noStatusGroup.groupId, 1); @@ -64,11 +67,13 @@ async function createKanbanBoardRow() { async function moveKanbanBoardRow() { const view = await createTestDatabaseView(ViewLayoutPB.Board); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); // Create row in no status group const firstGroup = databaseController.groups.getValue()[1]; const secondGroup = databaseController.groups.getValue()[2]; + // subscribe the group changes firstGroup.subscribe({ onRemoveRow: (groupId, deleteRowId) => { @@ -101,6 +106,7 @@ async function moveKanbanBoardRow() { }); const row = firstGroup.rowAtIndex(0).unwrap(); + await databaseController.moveGroupRow(row.id, secondGroup.groupId); assert(firstGroup.rows.length === 2); @@ -115,11 +121,13 @@ async function moveKanbanBoardRow() { async function createKanbanBoardColumn() { const view = await createTestDatabaseView(ViewLayoutPB.Board); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); // Create row in no status group const firstGroup = databaseController.groups.getValue()[1]; const secondGroup = databaseController.groups.getValue()[2]; + await databaseController.moveGroup(firstGroup.groupId, secondGroup.groupId); assert(databaseController.groups.getValue()[1].groupId === secondGroup.groupId); @@ -130,6 +138,7 @@ async function createKanbanBoardColumn() { async function createColumnInBoard() { const view = await createTestDatabaseView(ViewLayoutPB.Board); const databaseController = await openTestDatabase(view.id); + await databaseController.open().then((result) => result.unwrap()); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -139,6 +148,7 @@ async function createColumnInBoard() { // Create a option which will cause creating a new group const name = 'New column'; + await createSingleSelectOptions(view.id, singleSelect, [name]); // Wait the backend posting the notification to update the groups diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts index a95ae94a776e..9948a2b189ed 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts @@ -17,7 +17,7 @@ export function useLoadTrash() { }, [controller, dispatch]); const subscribeToTrash = useCallback(async () => { - controller.subscribe({ + await controller.subscribe({ onTrashChanged: (trash) => { dispatch(trashActions.onTrashChanged(trash.map(trashPBToTrash))); }, @@ -33,7 +33,7 @@ export function useLoadTrash() { useEffect(() => { return () => { - controller.dispose(); + void controller.dispose(); }; }, [controller]); @@ -52,7 +52,7 @@ export function useTrashActions() { useEffect(() => { return () => { - controller.dispose(); + void controller.dispose(); }; }, [controller]); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx index 23a78a9bef29..6387437162de 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx @@ -28,11 +28,11 @@ function Trash() { <div className={'flex items-center justify-between'}> <div className={'text-2xl font-bold'}>{t('trash.text')}</div> <div className={'flex items-center justify-end'}> - <Button color={'inherit'} onClick={(e) => onClickRestoreAll()}> + <Button color={'inherit'} onClick={() => onClickRestoreAll()}> <RestoreOutlined /> <span className={'ml-1'}>{t('trash.restoreAll')}</span> </Button> - <Button color={'error'} onClick={(e) => onClickDeleteAll()}> + <Button color={'error'} onClick={() => onClickDeleteAll()}> <DeleteOutline /> <span className={'ml-1'}>{t('trash.deleteAll')}</span> </Button> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx index 78ce773423b2..3d970257fd85 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx @@ -23,10 +23,10 @@ function TrashItem({ return ( <ListItem - onMouseEnter={(e) => { + onMouseEnter={() => { setHoverId(item.id); }} - onMouseLeave={(e) => { + onMouseLeave={() => { setHoverId(''); }} key={item.id} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/user/application/notifications/parser.ts b/frontend/appflowy_tauri/src/appflowy_app/components/user/application/notifications/parser.ts index 4e3a57af4ba0..866d910e8361 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/user/application/notifications/parser.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/user/application/notifications/parser.ts @@ -10,6 +10,7 @@ export class UserNotificationParser extends NotificationParser<UserNotification> params.callback, (ty) => { const notification = UserNotification[ty]; + if (isUserNotification(notification)) { return UserNotification[notification]; } else { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/user/application/notifications/user_listener.ts b/frontend/appflowy_tauri/src/appflowy_app/components/user/application/notifications/user_listener.ts index d63963eaf0da..112ee9b6a211 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/user/application/notifications/user_listener.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/user/application/notifications/user_listener.ts @@ -18,6 +18,7 @@ export class UserNotificationListener extends AFNotificationObserver<UserNotific } else { this.onProfileUpdate?.(result); } + break; default: break; @@ -26,6 +27,7 @@ export class UserNotificationListener extends AFNotificationObserver<UserNotific id: params.userId, onError: params.onError, }); + super(parser); this.onProfileUpdate = params.onProfileUpdate; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts index 4f8e54ad8901..fe6c432a550e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts @@ -90,6 +90,9 @@ export const blockConfig: Record<string, BlockConfig> = { [BlockType.DividerBlock]: { canAddChild: false, }, + [BlockType.GridBlock]: { + canAddChild: false, + }, [BlockType.EquationBlock]: { canAddChild: false, defaultData: { diff --git a/frontend/appflowy_tauri/src/appflowy_app/hooks/ViewId.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/hooks/ViewId.hooks.ts new file mode 100644 index 000000000000..6711ece8c862 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/hooks/ViewId.hooks.ts @@ -0,0 +1,6 @@ +import { createContext, useContext } from 'react'; + +const ViewIdContext = createContext(''); + +export const ViewIdProvider = ViewIdContext.Provider; +export const useViewId = () => useContext(ViewIdContext); diff --git a/frontend/appflowy_tauri/src/appflowy_app/hooks/index.ts b/frontend/appflowy_tauri/src/appflowy_app/hooks/index.ts index d9223c236b0e..c29ddd04aa3a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/hooks/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/hooks/index.ts @@ -1 +1,2 @@ export * from './notification.hooks'; +export * from './ViewId.hooks'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/hooks/notification.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/hooks/notification.hooks.ts index 87327b71be76..73edd7059486 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/hooks/notification.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/hooks/notification.hooks.ts @@ -1,9 +1,8 @@ /* eslint-disable no-redeclare */ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { useEffect } from 'react'; import { listen } from '@tauri-apps/api/event'; -import { Ok, Err, Result } from 'ts-results'; import { SubscribeObject } from '@/services/backend/models/flowy-notification'; -import { FlowyError } from '@/services/backend/models/flowy-error'; import { DatabaseFieldChangesetPB, DatabaseNotification, @@ -14,33 +13,41 @@ import { ReorderSingleRowPB, RowsChangePB, RowsVisibilityChangePB, + SortChangesetNotificationPB, + FieldSettingsPB, } from '@/services/backend'; const NotificationPBMap = { [DatabaseNotification.DidUpdateViewRowsVisibility]: RowsVisibilityChangePB, [DatabaseNotification.DidUpdateViewRows]: RowsChangePB, [DatabaseNotification.DidReorderRows]: ReorderAllRowsPB, - [DatabaseNotification.DidReorderSingleRow]:ReorderSingleRowPB, - [DatabaseNotification.DidUpdateFields]:DatabaseFieldChangesetPB, - [DatabaseNotification.DidGroupByField]:GroupChangesPB, - [DatabaseNotification.DidUpdateNumOfGroups]:GroupChangesPB, + [DatabaseNotification.DidReorderSingleRow]: ReorderSingleRowPB, + [DatabaseNotification.DidUpdateFields]: DatabaseFieldChangesetPB, + [DatabaseNotification.DidGroupByField]: GroupChangesPB, + [DatabaseNotification.DidUpdateNumOfGroups]: GroupChangesPB, [DatabaseNotification.DidUpdateGroupRow]: GroupRowsNotificationPB, [DatabaseNotification.DidUpdateField]: FieldPB, [DatabaseNotification.DidUpdateCell]: null, + [DatabaseNotification.DidUpdateSort]: SortChangesetNotificationPB, + [DatabaseNotification.DidUpdateFieldSettings]: FieldSettingsPB, }; type NotificationMap = typeof NotificationPBMap; type NotificationEnum = keyof NotificationMap; -type NullableInstanceType<K extends ((abstract new (...args: any) => any) | null)> = K extends (abstract new (...args: any) => any) ? InstanceType<K> : void; +type NullableInstanceType<K extends (abstract new (...args: any) => any) | null> = K extends abstract new ( + ...args: any +) => any + ? InstanceType<K> + : void; -type NotificationHandler<K extends NotificationEnum> = (result: Result<NullableInstanceType<NotificationMap[K]>, FlowyError>) => void; +type NotificationHandler<K extends NotificationEnum> = (result: NullableInstanceType<NotificationMap[K]>) => void; /** * Subscribes to a set of notifications. - * - * This function subscribes to notifications defined by the `NotificationEnum` and + * + * This function subscribes to notifications defined by the `NotificationEnum` and * calls the appropriate `NotificationHandler` when each type of notification is received. * * @param {Object} callbacks - An object containing handlers for various notification types. @@ -48,9 +55,9 @@ type NotificationHandler<K extends NotificationEnum> = (result: Result<NullableI * * @param {Object} [options] - Optional settings for the subscription. * @param {string} [options.id] - An optional ID. If provided, only notifications with a matching ID will be processed. - * + * * @returns {Promise<() => void>} A Promise that resolves to an unsubscribe function. - * + * * @example * subscribeNotifications({ * [DatabaseNotification.DidUpdateField]: (result) => { @@ -75,16 +82,16 @@ type NotificationHandler<K extends NotificationEnum> = (result: Result<NullableI * // ... * // To unsubscribe, call `unsubscribe()` * }); - * + * * @throws {Error} Throws an error if unable to subscribe. */ export function subscribeNotifications( callbacks: { [K in NotificationEnum]?: NotificationHandler<K>; }, - options?: { id?: string }, + options?: { id?: string } ): Promise<() => void> { - return listen<ReturnType<typeof SubscribeObject.prototype.toObject>>('af-notification', event => { + return listen<ReturnType<typeof SubscribeObject.prototype.toObject>>('af-notification', (event) => { const subject = SubscribeObject.fromObject(event.payload); const { id, ty } = subject; @@ -101,13 +108,12 @@ export function subscribeNotifications( } if (subject.has_error) { - const error = FlowyError.deserializeBinary(subject.error); - - callback(Err(error)); + // const error = FlowyError.deserialize(subject.error); + return; } else { const { payload } = subject; - callback(pb ? Ok(pb.deserializeBinary(payload)) : Ok.EMPTY); + pb ? callback(pb.deserialize(payload)) : callback(); } }); } @@ -115,7 +121,7 @@ export function subscribeNotifications( export function subscribeNotification<K extends NotificationEnum>( notification: K, callback: NotificationHandler<K>, - options?: { id?: string }, + options?: { id?: string } ): Promise<() => void> { return subscribeNotifications({ [notification]: callback }, options); } @@ -123,7 +129,7 @@ export function subscribeNotification<K extends NotificationEnum>( export function useNotification<K extends NotificationEnum>( notification: K, callback: NotificationHandler<K>, - options: { id?: string }, + options: { id?: string } ): void { const { id } = options; @@ -131,7 +137,7 @@ export function useNotification<K extends NotificationEnum>( const unsubscribePromise = subscribeNotification(notification, callback, { id }); return () => { - void unsubscribePromise.then(fn => fn()); + void unsubscribePromise.then((fn) => fn()); }; }, [callback, id, notification]); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/i18n/config.ts b/frontend/appflowy_tauri/src/appflowy_app/i18n/config.ts index 59f0d77cf2f2..202b6a4220df 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/i18n/config.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/i18n/config.ts @@ -3,7 +3,7 @@ import LanguageDetector from 'i18next-browser-languagedetector'; import { initReactI18next } from 'react-i18next'; import resourcesToBackend from 'i18next-resources-to-backend'; -i18next +void i18next .use(resourcesToBackend((language: string) => import(`./translations/${language}.json`))) .use(LanguageDetector) .use(initReactI18next) diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/database/index.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/database/index.ts deleted file mode 100644 index a478f9c12d32..000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/database/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './types'; -export * from './transform'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/database/transform.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/database/transform.ts deleted file mode 100644 index cb35f1e8e233..000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/database/transform.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { CellPB, ChecklistCellDataPB, DateCellDataPB, FieldPB, FieldType, SelectOptionCellDataPB, URLCellDataPB } from '@/services/backend'; -import type { Database } from './types'; - -export const fieldPbToField = (fieldPb: FieldPB): Database.Field => ({ - id: fieldPb.id, - name: fieldPb.name, - type: fieldPb.field_type, - visibility: fieldPb.visibility, - width: fieldPb.width, - isPrimary: fieldPb.is_primary, -}); - -const toDateCellData = (pb: DateCellDataPB): Database.DateTimeCellData => ({ - date: pb.date, - time: pb.time, - timestamp: pb.timestamp, - includeTime: pb.include_time, -}); - -const toSelectCellData = (pb: SelectOptionCellDataPB): Database.SelectCellData => { - return { - options: pb.options.map(option => ({ - id: option.id, - name: option.name, - color: option.color, - })), - selectOptions: pb.select_options.map(option => ({ - id: option.id, - name: option.name, - color: option.color, - })), - }; -}; - -const toURLCellData = (pb: URLCellDataPB): Database.UrlCellData => ({ - url: pb.url, - content: pb.content, -}); - -const toChecklistCellData = (pb: ChecklistCellDataPB): Database.ChecklistCellData => ({ - selectedOptions: pb.selected_options.map(({ id }) => id), - percentage: pb.percentage, -}); - -function parseCellData(fieldType: FieldType, data: Uint8Array) { - switch (fieldType) { - case FieldType.RichText: - case FieldType.Number: - case FieldType.Checkbox: - return new TextDecoder().decode(data); - case FieldType.DateTime: - case FieldType.LastEditedTime: - case FieldType.CreatedTime: - return toDateCellData(DateCellDataPB.deserializeBinary(data)); - case FieldType.SingleSelect: - case FieldType.MultiSelect: - return toSelectCellData(SelectOptionCellDataPB.deserializeBinary(data)); - case FieldType.URL: - return toURLCellData(URLCellDataPB.deserializeBinary(data)); - case FieldType.Checklist: - return toChecklistCellData(ChecklistCellDataPB.deserializeBinary(data)); - } -} - -export const cellPbToCell = (cellPb: CellPB, fieldType: FieldType): Database.Cell => { - return { - rowId: cellPb.row_id, - fieldId: cellPb.field_id, - fieldType: fieldType, - data: parseCellData(fieldType, cellPb.data), - }; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/database/types.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/database/types.ts deleted file mode 100644 index 445406c4906a..000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/database/types.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { - CalendarLayoutPB, - DatabaseLayoutPB, - DateFormatPB, - FieldType, - NumberFormatPB, - SelectOptionColorPB, - SelectOptionConditionPB, - SortConditionPB, - TextFilterConditionPB, - TimeFormatPB, -} from '@/services/backend'; - - -export interface Database { - id: string; - viewId: string; - name: string; - fields: Database.UndeterminedField[]; - rows: Database.Row[]; - layoutType: DatabaseLayoutPB; - layoutSetting: Database.GridLayoutSetting | Database.CalendarLayoutSetting; - isLinked: boolean; -} - -// eslint-disable-next-line @typescript-eslint/no-namespace, no-redeclare -export namespace Database { - - export interface GridLayoutSetting { - filters?: UndeterminedFilter[]; - groups?: Group[]; - sorts?: Sort[]; - } - - export interface CalendarLayoutSetting { - fieldId?: string; - layoutTy?: CalendarLayoutPB; - firstDayOfWeek?: number; - showWeekends?: boolean; - showWeekNumbers?: boolean; - } - - export interface Field { - id: string; - name: string; - type: FieldType; - typeOption?: unknown; - visibility?: boolean; - width?: number; - isPrimary?: boolean; - } - - export interface NumberTypeOption { - format?: NumberFormatPB; - scale?: number; - symbol?: string; - name?: string; - } - - export interface NumberField extends Field { - type: FieldType.Number; - typeOption: NumberTypeOption; - } - - export interface DateTimeTypeOption { - dateFormat?: DateFormatPB; - timeFormat?: TimeFormatPB; - timezoneId?: string; - fieldType?: FieldType; - } - - export interface DateTimeField extends Field { - type: FieldType.DateTime; - typeOption: DateTimeTypeOption; - } - - export interface SelectOption { - id: string; - name: string; - color: SelectOptionColorPB; - } - - export interface SelectTypeOption { - options?: SelectOption[]; - disableColor?: boolean; - } - - export interface SelectField extends Field { - type: FieldType.SingleSelect | FieldType.MultiSelect; - typeOption: SelectTypeOption; - } - - export interface ChecklistTypeOption { - config?: string; - } - - export interface ChecklistField extends Field { - type: FieldType.Checklist; - typeOption: ChecklistTypeOption; - } - - export type UndeterminedField = NumberField | DateTimeField | SelectField | ChecklistField | Field; - - export interface Sort { - id: string; - fieldId: string; - fieldType: FieldType; - condition: SortConditionPB; - } - - export interface Group { - id: string; - fieldId: string; - } - - export interface Filter { - id: string; - fieldId: string; - fieldType: FieldType; - data: unknown; - } - - export interface TextFilter extends Filter { - fieldType: FieldType.RichText; - data: TextFilterCondition; - } - - export interface TextFilterCondition { - condition?: TextFilterConditionPB; - content?: string; - } - - export interface SelectFilter extends Filter { - fieldType: FieldType.SingleSelect | FieldType.MultiSelect; - data: SelectFilterCondition; - } - - export interface SelectFilterCondition { - condition?: SelectOptionConditionPB; - /** - * link to [SelectOption's id property]{@link SelectOption#id}. - */ - optionIds?: string[]; - } - - export type UndeterminedFilter = TextFilter | SelectFilter | Filter; - - export interface Row { - id: string; - documentId?: string; - icon?: string; - cover?: string; - createdAt?: number; - modifiedAt?: number; - height?: number; - visibility?: boolean; - } - - export interface Cell { - rowId: string; - fieldId: string; - fieldType: FieldType; - data: unknown; - } - - export interface TextCell extends Cell { - fieldType: FieldType.RichText; - data: string; - } - - export interface NumberCell extends Cell { - fieldType: FieldType.Number; - data: string; - } - - export interface CheckboxCell extends Cell { - fieldType: FieldType.Checkbox; - data: 'Yes' | 'No'; - } - - export interface UrlCell extends Cell { - fieldType: FieldType.URL; - data: UrlCellData; - } - - export interface UrlCellData { - url: string; - content?: string; - } - - export interface SelectCell extends Cell { - fieldType: FieldType.SingleSelect | FieldType.MultiSelect; - data: SelectCellData; - } - - export interface SelectCellData { - options?: SelectOption[]; - selectOptions?: SelectOption[]; - } - - export interface DateTimeCell extends Cell { - fieldType: FieldType.DateTime; - data: DateTimeCellData; - } - - export interface DateTimeCellData { - date?: string; - time?: string; - timestamp?: number; - includeTime?: boolean; - } - - export interface ChecklistCell extends Cell { - fieldType: FieldType.Checklist; - data: ChecklistCellData; - } - - export interface ChecklistCellData { - /** - * link to [SelectOption's id property]{@link SelectOption#id}. - */ - selectedOptions?: string[]; - percentage?: number; - } - - export type UndeterminedCell = TextCell | NumberCell | DateTimeCell | SelectCell | CheckboxCell | UrlCell | ChecklistCell; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts index a7a122dc2993..8e8e2f97bf7b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts @@ -3,12 +3,6 @@ import { BlockActionTypePB } from '@/services/backend'; import { Sources } from 'quill'; import React from 'react'; -export interface DocumentBlockJSON { - type: BlockType; - data: BlockData<any>; - children: DocumentBlockJSON[]; -} - export interface RangeStatic { id: string; length: number; @@ -29,6 +23,7 @@ export enum BlockType { CalloutBlock = 'callout', DividerBlock = 'divider', ImageBlock = 'image', + GridBlock = 'grid', } export interface EauqtionBlockData { @@ -61,11 +56,9 @@ export interface QuoteBlockData extends TextBlockData { export interface CalloutBlockData extends TextBlockData { icon: string; } - +// eslint-disable-next-line @typescript-eslint/no-explicit-any export type TextBlockData = Record<string, any>; -export interface DividerBlockData {} - export enum Align { Left = 'left', Center = 'center', @@ -88,8 +81,15 @@ export interface PageBlockData extends TextBlockData { cover?: string; coverType?: CoverType; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Data = any; + +export interface ReferenceBlockData { + viewId: string; +} -export type BlockData<Type> = Type extends BlockType.HeadingBlock +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type BlockData<Type = any> = Type extends BlockType.HeadingBlock ? HeadingBlockData : Type extends BlockType.PageBlock ? PageBlockData @@ -103,8 +103,6 @@ export type BlockData<Type> = Type extends BlockType.HeadingBlock ? NumberedListBlockData : Type extends BlockType.ToggleListBlock ? ToggleListBlockData - : Type extends BlockType.DividerBlock - ? DividerBlockData : Type extends BlockType.CalloutBlock ? CalloutBlockData : Type extends BlockType.EquationBlock @@ -113,12 +111,15 @@ export type BlockData<Type> = Type extends BlockType.HeadingBlock ? ImageBlockData : Type extends BlockType.TextBlock ? TextBlockData - : any; + : Type extends BlockType.GridBlock + ? ReferenceBlockData + : Data; +// eslint-disable-next-line @typescript-eslint/no-explicit-any export interface NestedBlock<Type = any> { id: string; type: BlockType; - data: BlockData<Type> | any; + data: BlockData<Type>; parent: string | null; children: string; externalId?: string; @@ -152,7 +153,6 @@ export interface SlashCommandState { export enum SlashCommandOptionKey { TEXT, - PAGE, TODO, BULLET, NUMBER, @@ -166,12 +166,14 @@ export enum SlashCommandOptionKey { HEADING_2, HEADING_3, IMAGE, + GRID_REFERENCE, } export interface SlashCommandOption { type: BlockType; - data?: BlockData<any>; + data?: BlockData; key: SlashCommandOptionKey; + onClick?: () => void; } export enum SlashCommandGroup { @@ -273,7 +275,7 @@ export interface BlockConfig { /** * The default data of the block */ - defaultData?: BlockData<any>; + defaultData?: BlockData; /** * The props that will be passed to the text split function diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/index.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/index.ts index d10ea3cdff38..7d1466630a21 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/interfaces/index.ts @@ -1,7 +1,6 @@ import { ThemeModePB as ThemeMode } from '@/services/backend'; export { ThemeMode }; -export interface Document {} export interface UserSetting { theme?: Theme; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_bd_svc.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_bd_svc.ts index 99d3a64f0606..30ec4e58124b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_bd_svc.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_bd_svc.ts @@ -18,6 +18,7 @@ class CellBackendService { row_id: cellId.rowId, cell_changeset: data, }); + return DatabaseEventUpdateCell(payload); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_cache.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_cache.ts index a19ad13d27b1..d81415660c99 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_cache.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_cache.ts @@ -14,6 +14,7 @@ export class CellCache { remove = (key: CellCacheKey) => { const cellDataByRowId = this.cellDataByFieldId.get(key.fieldId); + if (cellDataByRowId !== undefined) { cellDataByRowId.delete(key.rowId); } @@ -25,8 +26,10 @@ export class CellCache { insert = (key: CellCacheKey, value: any) => { const cellDataByRowId = this.cellDataByFieldId.get(key.fieldId); + if (cellDataByRowId === undefined) { const map = new Map(); + map.set(key.rowId, value); this.cellDataByFieldId.set(key.fieldId, map); } else { @@ -36,10 +39,12 @@ export class CellCache { get<T>(key: CellCacheKey): Option<T> { const cellDataByRowId = this.cellDataByFieldId.get(key.fieldId); + if (cellDataByRowId === undefined) { return None; } else { const value = cellDataByRowId.get(key.rowId); + if (typeof value === typeof undefined) { return None; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_controller.ts index d569c547ae3d..a753fb9ab130 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_controller.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_controller.ts @@ -55,6 +55,7 @@ export class CellController<T, D> { if (this.cellDataLoader.reloadOnFieldChanged) { await this._loadCellData(); } + this.subscribeCallbacks?.onFieldChanged?.(); }, }); @@ -71,6 +72,7 @@ export class CellController<T, D> { getTypeOption = async <P extends TypeOptionParser<PD>, PD>(parser: P) => { const result = await this.fieldBackendService.getTypeOptionData(this.cellIdentifier.fieldType); + if (result.ok) { return Ok(parser.fromBuffer(result.val.type_option_data)); } else { @@ -80,6 +82,7 @@ export class CellController<T, D> { saveCellData = async (data: D) => { const result = await this.cellDataPersistence.save(data); + if (result.err) { Log.error(result.val); } @@ -90,17 +93,21 @@ export class CellController<T, D> { /// subscribers of the [onCellChanged] will get noticed getCellData = async (): Promise<Option<T>> => { const cellData = this.cellCache.get<T>(this.cacheKey); + if (cellData.none) { await this._loadCellData(); return this.cellCache.get<T>(this.cacheKey); } + return cellData; }; private _loadCellData = async () => { const result = await this.cellDataLoader.loadData(); + if (result.ok) { const cellData = result.val; + if (cellData.some) { this.cellCache.insert(this.cacheKey, cellData.val); this.cellDataNotifier.cellData = cellData; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_observer.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_observer.ts index dba0290d7af6..0cd5ae35c2e8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_observer.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_observer.ts @@ -29,6 +29,7 @@ export class CellObserver { } else { this.notifier?.notify(result); } + return; default: break; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/data_parser.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/data_parser.ts index 86b2198b4e1b..80afed8f9f29 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/data_parser.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/data_parser.ts @@ -1,4 +1,3 @@ -import utf8 from 'utf8'; import { CellBackendService, CellIdentifier } from './cell_bd_svc'; import { SelectOptionCellDataPB, URLCellDataPB, DateCellDataPB } from '@/services/backend'; import { Err, None, Ok, Option, Some } from 'ts-results'; @@ -19,6 +18,7 @@ class CellDataLoader<T> { loadData = async () => { const result = await this.service.getCell(this.cellId); + if (result.ok) { return Ok(this.parser.parserData(result.val.data)); } else { @@ -48,6 +48,7 @@ class SelectOptionCellDataParser extends CellDataParser<SelectOptionCellDataPB> if (data.length === 0) { return None; } + return Some(SelectOptionCellDataPB.deserializeBinary(data)); } } @@ -57,6 +58,7 @@ class URLCellDataParser extends CellDataParser<URLCellDataPB> { if (data.length === 0) { return None; } + return Some(URLCellDataPB.deserializeBinary(data)); } } diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/data_persistence.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/data_persistence.ts index 9066b61150ae..ea8e9e2953c2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/data_persistence.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/data_persistence.ts @@ -25,10 +25,12 @@ export class DateCellDataPersistence extends CellDataPersistence<CalendarData> { save(data: CalendarData): Promise<Result<void, FlowyError>> { const payload = DateChangesetPB.fromObject({ cell_id: _makeCellId(this.cellIdentifier) }); + payload.date = (data.date.getTime() / 1000) | 0; if (data.time !== undefined) { payload.time = data.time; } + payload.include_time = data.includeTime; return DatabaseEventUpdateDateCell(payload); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/select_option_bd_svc.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/select_option_bd_svc.ts index 068ae60797ce..5e3e86fadc69 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/select_option_bd_svc.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/select_option_bd_svc.ts @@ -40,6 +40,7 @@ export class SelectOptionCellBackendService { }); const result = await DatabaseEventCreateSelectOption(payload); + if (result.ok) { return await this._insertOption(result.val, params.isSelect || true); } else { @@ -47,7 +48,7 @@ export class SelectOptionCellBackendService { } }; - private _insertOption = (option: SelectOptionPB, isSelect: boolean) => { + private _insertOption = (option: SelectOptionPB, _: boolean) => { const payload = RepeatedSelectOptionPayload.fromObject({ view_id: this.cellIdentifier.viewId, field_id: this.cellIdentifier.fieldId, @@ -73,6 +74,7 @@ export class SelectOptionCellBackendService { field_id: this.cellIdentifier.fieldId, row_id: this.cellIdentifier.rowId, }); + payload.items.push(...options); return DatabaseEventDeleteSelectOption(payload); }; @@ -83,12 +85,14 @@ export class SelectOptionCellBackendService { selectOption = (optionIds: string[]) => { const payload = SelectOptionCellChangesetPB.fromObject({ cell_identifier: this._cellIdentifier() }); + payload.insert_option_ids.push(...optionIds); return DatabaseEventUpdateSelectOptionCell(payload); }; unselectOption = (optionIds: string[]) => { const payload = SelectOptionCellChangesetPB.fromObject({ cell_identifier: this._cellIdentifier() }); + payload.delete_option_ids.push(...optionIds); return DatabaseEventUpdateSelectOptionCell(payload); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/database_bd_svc.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/database_bd_svc.ts index 7a03c82beb5a..a38dc176c4e7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/database_bd_svc.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/database_bd_svc.ts @@ -19,7 +19,6 @@ import { MoveGroupRowPayloadPB, MoveRowPayloadPB, RowIdPB, - DatabaseEventUpdateDatabaseSetting, DuplicateFieldPayloadPB, DatabaseEventDuplicateField, } from '@/services/backend/events/flowy-database2'; @@ -92,6 +91,7 @@ export class DatabaseBackendService { moveRow = async (fromRowId: string, toRowId: string) => { const payload = MoveRowPayloadPB.fromObject({ view_id: this.viewId, from_row_id: fromRowId, to_row_id: toRowId }); + return DatabaseEventMoveRow(payload); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/database_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/database_controller.ts index f4e94f4790b9..39d1db308a98 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/database_controller.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/database_controller.ts @@ -76,7 +76,6 @@ export class DatabaseController { // load database initial data await this.fieldController.loadFields(database.fields); - const loadGroupResult = await this.loadGroup(); this.databaseViewCache.initializeWithRows(database.rows); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_bd_svc.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_bd_svc.ts index 3580aece0b8e..10c901067881 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_bd_svc.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_bd_svc.ts @@ -55,11 +55,13 @@ export class FieldBackendService { deleteField = () => { const payload = DeleteFieldPayloadPB.fromObject({ view_id: this.viewId, field_id: this.fieldId }); + return DatabaseEventDeleteField(payload); }; duplicateField = () => { const payload = DuplicateFieldPayloadPB.fromObject({ view_id: this.viewId, field_id: this.fieldId }); + return DatabaseEventDuplicateField(payload); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_controller.ts index 46902ddef4ec..2234c06dbd24 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_controller.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_controller.ts @@ -29,6 +29,7 @@ export class FieldController { loadFields = async (fieldIds: FieldIdPB[]) => { const result = await this.backendService.getFields(fieldIds); + if (result.ok) { this.numOfFieldsNotifier.fieldInfos = result.val.map((field) => new FieldInfo(field)); } else { @@ -47,6 +48,7 @@ export class FieldController { onFieldsChanged: (result) => { if (result.ok) { const changeset = result.val; + this._deleteFields(changeset.deleted_fields); this._insertFields(changeset.inserted_fields); this._updateFields(changeset.updated_fields); @@ -66,6 +68,7 @@ export class FieldController { const predicate = (element: FieldInfo): boolean => { return !deletedFieldIds.includes(element.field.id); }; + this.numOfFieldsNotifier.fieldInfos = [...this.fieldInfos].filter(predicate); }; @@ -73,9 +76,12 @@ export class FieldController { if (insertedFields.length === 0) { return; } + const newFieldInfos = [...this.fieldInfos]; + insertedFields.forEach((insertedField) => { const fieldInfo = new FieldInfo(insertedField.field); + if (newFieldInfos.length > insertedField.index) { newFieldInfos.splice(insertedField.index, 0, fieldInfo); } else { @@ -91,10 +97,12 @@ export class FieldController { } const newFieldInfos = [...this.fieldInfos]; + updatedFields.forEach((updatedField) => { const index = newFieldInfos.findIndex((fieldInfo) => { return fieldInfo.field.id === updatedField.id; }); + if (index !== -1) { newFieldInfos.splice(index, 1, new FieldInfo(updatedField)); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_observer.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_observer.ts index d6a3e307b272..162c5c972aaa 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_observer.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_observer.ts @@ -26,6 +26,7 @@ export class DatabaseFieldChangesetObserver { } else { this.notifier?.notify(result); } + return; default: break; @@ -64,6 +65,7 @@ export class DatabaseFieldObserver { } else { this._notifier?.notify(result); } + break; default: break; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/type_option/type_option_bd_svc.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/type_option/type_option_bd_svc.ts index 31da01b9d80c..240615a2e6b6 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/type_option/type_option_bd_svc.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/type_option/type_option_bd_svc.ts @@ -10,6 +10,7 @@ export class TypeOptionBackendService { createTypeOption = (fieldType: FieldType) => { const payload = CreateFieldPayloadPB.fromObject({ view_id: this.viewId, field_type: fieldType }); + return DatabaseEventCreateTypeOption(payload); }; @@ -19,6 +20,7 @@ export class TypeOptionBackendService { field_id: fieldId, field_type: fieldType, }); + return DatabaseEventGetTypeOption(payload); }; @@ -28,6 +30,7 @@ export class TypeOptionBackendService { field_id: fieldId, field_type: fieldType, }); + return DatabaseEventUpdateFieldType(payload); }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/type_option/type_option_context.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/type_option/type_option_context.ts index 7e1f55bc3043..ce1b05251d3a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/type_option/type_option_context.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/type_option/type_option_context.ts @@ -22,6 +22,7 @@ abstract class TypeOptionSerde<T> { // RichText export function makeRichTextTypeOptionContext(controller: TypeOptionController): RichTextTypeOptionContext { const parser = new RichTextTypeOptionSerde(); + return new TypeOptionContext<string>(parser, controller); } @@ -40,6 +41,7 @@ class RichTextTypeOptionSerde extends TypeOptionSerde<string> { // Number export function makeNumberTypeOptionContext(controller: TypeOptionController): NumberTypeOptionContext { const parser = new NumberTypeOptionSerde(); + return new TypeOptionContext<NumberTypeOptionPB>(parser, controller); } @@ -58,6 +60,7 @@ class NumberTypeOptionSerde extends TypeOptionSerde<NumberTypeOptionPB> { // Checkbox export function makeCheckboxTypeOptionContext(controller: TypeOptionController): CheckboxTypeOptionContext { const parser = new CheckboxTypeOptionSerde(); + return new TypeOptionContext<CheckboxTypeOptionPB>(parser, controller); } @@ -76,6 +79,7 @@ class CheckboxTypeOptionSerde extends TypeOptionSerde<CheckboxTypeOptionPB> { // URL export function makeURLTypeOptionContext(controller: TypeOptionController): URLTypeOptionContext { const parser = new URLTypeOptionSerde(); + return new TypeOptionContext<URLTypeOptionPB>(parser, controller); } @@ -94,6 +98,7 @@ class URLTypeOptionSerde extends TypeOptionSerde<URLTypeOptionPB> { // Date export function makeDateTypeOptionContext(controller: TypeOptionController): DateTypeOptionContext { const parser = new DateTypeOptionSerde(); + return new TypeOptionContext<DateTypeOptionPB>(parser, controller); } @@ -112,6 +117,7 @@ class DateTypeOptionSerde extends TypeOptionSerde<DateTypeOptionPB> { // SingleSelect export function makeSingleSelectTypeOptionContext(controller: TypeOptionController): SingleSelectTypeOptionContext { const parser = new SingleSelectTypeOptionSerde(); + return new TypeOptionContext<SingleSelectTypeOptionPB>(parser, controller); } @@ -130,6 +136,7 @@ class SingleSelectTypeOptionSerde extends TypeOptionSerde<SingleSelectTypeOption // Multi-select export function makeMultiSelectTypeOptionContext(controller: TypeOptionController): MultiSelectTypeOptionContext { const parser = new MultiSelectTypeOptionSerde(); + return new TypeOptionContext<MultiSelectTypeOptionPB>(parser, controller); } @@ -148,6 +155,7 @@ class MultiSelectTypeOptionSerde extends TypeOptionSerde<MultiSelectTypeOptionPB // Checklist export function makeChecklistTypeOptionContext(controller: TypeOptionController): ChecklistTypeOptionContext { const parser = new ChecklistTypeOptionSerde(); + return new TypeOptionContext<ChecklistTypeOptionPB>(parser, controller); } @@ -184,8 +192,10 @@ export class TypeOptionContext<T> { getTypeOption = async (): Promise<Result<T, FlowyError>> => { const result = await this.controller.getTypeOption(); + if (result.ok) { const typeOption = this.parser.deserialize(result.val.type_option_data); + this.typeOption = Some(typeOption); return Ok(typeOption); } else { diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/type_option/type_option_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/type_option/type_option_controller.ts index a13196a236d1..a315140d9485 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/type_option/type_option_controller.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/type_option/type_option_controller.ts @@ -48,18 +48,23 @@ export class TypeOptionController { throw Error('Unexpected empty type option data. Should call initialize first'); } } + return new FieldInfo(this.typeOptionData.val.field); }; switchToField = async (fieldType: FieldType) => { const result = await this.typeOptionBackendSvc.updateTypeOptionType(this.fieldId, fieldType); + if (result.ok) { const getResult = await this.typeOptionBackendSvc.getTypeOption(this.fieldId, fieldType); + if (getResult.ok) { this.updateTypeOptionData(getResult.val); } + return getResult; } + return result; }; @@ -114,6 +119,7 @@ export class TypeOptionController { if (this.fieldBackendSvc === undefined) { Log.error('Unexpected empty field backend service'); } + return this.fieldBackendSvc?.deleteField(); }; @@ -121,6 +127,7 @@ export class TypeOptionController { if (this.fieldBackendSvc === undefined) { Log.error('Unexpected empty field backend service'); } + return this.fieldBackendSvc?.duplicateField(); }; @@ -130,6 +137,7 @@ export class TypeOptionController { if (result.ok) { this.updateTypeOptionData(result.val); } + return result; }); }; @@ -139,6 +147,7 @@ export class TypeOptionController { if (result.ok) { this.updateTypeOptionData(result.val); } + return result; }); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/group/group_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/group/group_controller.ts index 71e498bf9ac5..0a5c8de4afef 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/group/group_controller.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/group/group_controller.ts @@ -1,11 +1,4 @@ -import { - DatabaseNotification, - FlowyError, - GroupPB, - GroupRowsNotificationPB, - RowMetaPB, - RowPB, -} from '@/services/backend'; +import { DatabaseNotification, FlowyError, GroupPB, GroupRowsNotificationPB, RowMetaPB } from '@/services/backend'; import { ChangeNotifier } from '$app/utils/change_notifier'; import { None, Ok, Option, Result, Some } from 'ts-results'; import { DatabaseNotificationObserver } from '../notifications/observer'; @@ -48,6 +41,7 @@ export class DatabaseGroupController { if (this.group.rows.length < index) { return None; } + return Some(this.group.rows[index]); }; @@ -56,6 +50,7 @@ export class DatabaseGroupController { onRowsChanged: (result) => { if (result.ok) { const changeset = result.val; + // Delete changeset.deleted_rows.forEach((deletedRowId) => { this.group.rows = this.group.rows.filter((row) => row.id !== deletedRowId); @@ -65,6 +60,7 @@ export class DatabaseGroupController { // Insert changeset.inserted_rows.forEach((insertedRow) => { let index: number | undefined = insertedRow.index; + if (insertedRow.has_index && this.group.rows.length > insertedRow.index) { this.group.rows.splice(index, 0, insertedRow.row_meta); } else { @@ -82,6 +78,7 @@ export class DatabaseGroupController { // Update changeset.updated_rows.forEach((updatedRow) => { const index = this.group.rows.findIndex((row) => row.id === updatedRow.id); + if (index !== -1) { this.group.rows[index] = updatedRow; this.callbacks?.onUpdateRow(this.group.group_id, updatedRow); @@ -134,6 +131,7 @@ class GroupDataObserver { } else { this.notifier?.notify(result); } + return; default: break; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/group/group_observer.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/group/group_observer.ts index 46cfb1096197..796735114594 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/group/group_observer.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/group/group_observer.ts @@ -33,6 +33,7 @@ export class DatabaseGroupObserver { } else { this.groupByNotifier?.notify(result); } + break; case DatabaseNotification.DidUpdateNumOfGroups: if (result.ok) { @@ -40,6 +41,7 @@ export class DatabaseGroupObserver { } else { this.groupChangesetNotifier?.notify(result); } + break; default: break; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/notifications/observer.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/notifications/observer.ts index 04a819d88405..210258cde892 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/notifications/observer.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/notifications/observer.ts @@ -11,6 +11,7 @@ export class DatabaseNotificationObserver extends AFNotificationObserver<Databas callback: params.parserHandler, id: params.id, }); + super(parser); } } diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/notifications/parser.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/notifications/parser.ts index 264a6036bd09..caac06d1baf9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/notifications/parser.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/notifications/parser.ts @@ -10,6 +10,7 @@ export class DatabaseNotificationParser extends NotificationParser<DatabaseNotif params.callback, (ty) => { const notification = DatabaseNotification[ty]; + if (isDatabaseNotification(notification)) { return DatabaseNotification[notification]; } else { diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/row/row_cache.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/row/row_cache.ts index 8cf7bc623048..a353fa6198a4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/row/row_cache.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/row/row_cache.ts @@ -1,9 +1,7 @@ import { - RowPB, InsertedRowPB, UpdatedRowPB, RowIdPB, - OptionalRowPB, RowsChangePB, RowsVisibilityChangePB, ReorderSingleRowPB, @@ -13,7 +11,7 @@ import { ChangeNotifier } from '$app/utils/change_notifier'; import { FieldInfo } from '../field/field_controller'; import { CellCache, CellCacheKey } from '../cell/cell_cache'; import { CellIdentifier } from '../cell/cell_bd_svc'; -import { DatabaseEventGetRow, DatabaseEventGetRowMeta } from '@/services/backend/events/flowy-database2'; +import { DatabaseEventGetRowMeta } from '@/services/backend/events/flowy-database2'; import { None, Option, Some } from 'ts-results'; import { Log } from '$app/utils/log'; @@ -40,10 +38,12 @@ export class RowCache { loadCells = async (rowId: string): Promise<CellByFieldId> => { const opRow = this.rowList.getRow(rowId); + if (opRow.some) { return this._toCellMap(opRow.val.row.id, this.getFieldInfos()); } else { const rowResult = await this._loadRow(rowId); + if (rowResult.ok) { this._refreshRow(rowResult.val); return this._toCellMap(rowId, this.getFieldInfos()); @@ -101,6 +101,7 @@ export class RowCache { applyReorderSingleRow = (reorderRow: ReorderSingleRowPB) => { const rowInfo = this.rowList.getRow(reorderRow.row_id); + if (rowInfo !== undefined) { this.rowList.move({ rowId: reorderRow.row_id, fromIndex: reorderRow.old_index, toIndex: reorderRow.new_index }); this.notifier.withChange(RowChangedReason.ReorderSingleRow, reorderRow.row_id); @@ -109,24 +110,29 @@ export class RowCache { private _refreshRow = (updatedRow: RowMetaPB) => { const option = this.rowList.getRowWithIndex(updatedRow.id); + if (option.some) { const { rowInfo, index } = option.val; + this.rowList.remove(rowInfo.row.id); this.rowList.insert(index, rowInfo.copyWith({ row: updatedRow })); } else { const newRowInfo = new RowInfo(this.viewId, this.getFieldInfos(), updatedRow); + this.rowList.push(newRowInfo); } }; private _loadRow = (rowId: string) => { const payload = RowIdPB.fromObject({ view_id: this.viewId, row_id: rowId }); + return DatabaseEventGetRowMeta(payload); }; private _deleteRows = (rowIds: string[]) => { rowIds.forEach((rowId) => { const deletedRow = this.rowList.remove(rowId); + if (deletedRow !== undefined) { this.notifier.withChange(RowChangedReason.Delete, deletedRow.rowInfo.row.id); } @@ -137,6 +143,7 @@ export class RowCache { rows.forEach((insertedRow) => { const rowInfo = this._toRowInfo(insertedRow.row_meta); const insertedIndex = this.rowList.insert(insertedRow.index, rowInfo); + if (insertedIndex !== undefined) { this.notifier.withChange(RowChangedReason.Insert, insertedIndex.rowId); } @@ -149,9 +156,11 @@ export class RowCache { } const rowInfos: RowInfo[] = []; + updatedRows.forEach((updatedRow) => { updatedRow.field_ids.forEach((fieldId) => { const key = new CellCacheKey(fieldId, updatedRow.row_meta.id); + this.cellCache.remove(key); }); @@ -159,6 +168,7 @@ export class RowCache { }); const updatedIndexs = this.rowList.insertRows(rowInfos); + updatedIndexs.forEach((row) => { this.notifier.withChange(RowChangedReason.Update, row.rowId); }); @@ -167,6 +177,7 @@ export class RowCache { private _hideRows = (rowIds: string[]) => { rowIds.forEach((rowId) => { const deletedRow = this.rowList.remove(rowId); + if (deletedRow !== undefined) { this.notifier.withChange(RowChangedReason.Delete, deletedRow.rowInfo.row.id); } @@ -196,6 +207,7 @@ export class RowCache { fieldInfos.forEach((fieldInfo) => { const identifier = new CellIdentifier(this.viewId, rowId, fieldInfo.field.id, fieldInfo.field.field_type); + cellIdentifierByFieldId.set(fieldInfo.field.id, identifier); }); @@ -213,6 +225,7 @@ class RowList { getRow = (rowId: string): Option<RowInfo> => { const rowInfo = this._rowInfoByRowId.get(rowId); + if (rowInfo === undefined) { return None; } else { @@ -221,23 +234,29 @@ class RowList { }; getRowWithIndex = (rowId: string): Option<{ rowInfo: RowInfo; index: number }> => { const rowInfo = this._rowInfoByRowId.get(rowId); + if (rowInfo !== undefined) { const index = this._rowInfos.indexOf(rowInfo, 0); + return Some({ rowInfo: rowInfo, index: index }); } + return None; }; indexOfRow = (rowId: string): number => { const rowInfo = this._rowInfoByRowId.get(rowId); + if (rowInfo !== undefined) { return this._rowInfos.indexOf(rowInfo, 0); } + return -1; }; push = (rowInfo: RowInfo) => { const index = this.indexOfRow(rowInfo.row.id); + if (index !== -1) { this._rowInfos.splice(index, 1, rowInfo); } else { @@ -249,8 +268,10 @@ class RowList { remove = (rowId: string): DeletedRow | undefined => { const result = this.getRowWithIndex(rowId); + if (result.some) { const { rowInfo, index } = result.val; + this._rowInfoByRowId.delete(rowInfo.row.id); this._rowInfos.splice(index, 1); return new DeletedRow(index, rowInfo); @@ -263,13 +284,16 @@ class RowList { const rowId = newRowInfo.row.id; // Calibrate where to insert let insertedIndex = insertIndex; + if (this._rowInfos.length <= insertedIndex) { insertedIndex = this._rowInfos.length; } + const result = this.getRowWithIndex(rowId); if (result.some) { const { index } = result.val; + // remove the old row info this._rowInfos.splice(index, 1); // insert the new row info to the insertedIndex @@ -285,8 +309,10 @@ class RowList { insertRows = (rowInfos: RowInfo[]) => { const map = new Map<string, InsertedRow>(); + rowInfos.forEach((rowInfo) => { const index = this.indexOfRow(rowInfo.row.id); + if (index !== -1) { this._rowInfos.splice(index, 1, rowInfo); this._rowInfoByRowId.set(rowInfo.row.id, rowInfo); @@ -299,8 +325,10 @@ class RowList { move = (params: { rowId: string; fromIndex: number; toIndex: number }) => { const currentIndex = this.indexOfRow(params.rowId); + if (currentIndex !== -1 && currentIndex !== params.toIndex) { const rowInfo = this.remove(params.rowId)?.rowInfo; + if (rowInfo !== undefined) { this.insert(params.toIndex, rowInfo); } @@ -312,6 +340,7 @@ class RowList { this._rowInfos = []; rowIds.forEach((rowId) => { const rowInfo = this._rowInfoByRowId.get(rowId); + if (rowInfo !== undefined) { this._rowInfos.push(rowInfo); } @@ -324,6 +353,7 @@ class RowList { setFieldInfos = (fieldInfos: readonly FieldInfo[]) => { const newRowInfos: RowInfo[] = []; + this._rowInfos.forEach((rowInfo) => { newRowInfos.push(rowInfo.copyWith({ fieldInfos: fieldInfos })); }); @@ -371,6 +401,7 @@ export class RowChangeNotifier extends ChangeNotifier<RowChanged> { withChange = (reason: RowChangedReason, rowId?: string) => { const newChange = new RowChanged(reason, rowId); + if (this._currentChanged !== newChange) { this._currentChanged = newChange; this.notify(this._currentChanged); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/view/database_view_cache.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/view/database_view_cache.ts index e099d3ba6bef..072dae5189a1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/view/database_view_cache.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/view/database_view_cache.ts @@ -1,7 +1,7 @@ import { DatabaseViewRowsObserver } from './view_row_observer'; import { RowCache, RowInfo } from '../row/row_cache'; import { FieldController } from '../field/field_controller'; -import { RowMetaPB, RowPB } from '@/services/backend'; +import { RowMetaPB } from '@/services/backend'; export class DatabaseViewCache { private readonly rowsObserver: DatabaseViewRowsObserver; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/view/view_row_observer.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/view/view_row_observer.ts index 7bc66f9115aa..2b7a67e4f183 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/view/view_row_observer.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/view/view_row_observer.ts @@ -47,6 +47,7 @@ export class DatabaseViewRowsObserver { } else { this.rowsVisibilityNotifier.notify(result); } + break; case DatabaseNotification.DidUpdateViewRows: if (result.ok) { @@ -54,6 +55,7 @@ export class DatabaseViewRowsObserver { } else { this.rowsNotifier.notify(result); } + break; case DatabaseNotification.DidReorderRows: if (result.ok) { @@ -61,6 +63,7 @@ export class DatabaseViewRowsObserver { } else { this.reorderRowsNotifier.notify(result); } + break; case DatabaseNotification.DidReorderSingleRow: if (result.ok) { @@ -68,6 +71,7 @@ export class DatabaseViewRowsObserver { } else { this.reorderSingleRowNotifier.notify(result); } + break; default: break; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/notifications/observer.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/notifications/observer.ts index ee149e571899..0d7a3d97d1b8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/notifications/observer.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/notifications/observer.ts @@ -11,6 +11,7 @@ export class DocumentNotificationObserver extends AFNotificationObserver<Documen callback: params.parserHandler, id: params.viewId, }); + super(parser); } } diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/notifications/parser.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/notifications/parser.ts index e29956803b32..8609148153c4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/notifications/parser.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/notifications/parser.ts @@ -10,6 +10,7 @@ export class DocumentNotificationParser extends NotificationParser<DocumentNotif params.callback, (ty) => { const notification = DocumentNotification[ty]; + if (isDocumentNotification(notification)) { return DocumentNotification[notification]; } else { diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/user/user_bd_svc.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/user/user_bd_svc.ts index 0cfc29ac0b21..8dec16134cb2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/user/user_bd_svc.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/user/user_bd_svc.ts @@ -1,8 +1,6 @@ import { nanoid } from '@reduxjs/toolkit'; import { AppearanceSettingsPB, - AuthTypePB, - ThemeModePB, UserEventGetAppearanceSetting, UserEventGetUserProfile, UserEventGetUserSetting, @@ -13,20 +11,17 @@ import { UserEventUpdateUserProfile, } from '@/services/backend/events/flowy-user'; import { - BlockActionPB, CreateWorkspacePayloadPB, SignInPayloadPB, SignUpPayloadPB, UpdateUserProfilePayloadPB, - WorkspaceIdPB, WorkspacePB, WorkspaceSettingPB, } from '@/services/backend'; import { FolderEventCreateWorkspace, - FolderEventOpenWorkspace, - FolderEventGetCurrentWorkspace, - FolderEventReadAllWorkspaces, + FolderEventGetCurrentWorkspaceSetting, + FolderEventReadCurrentWorkspace, } from '@/services/backend/events/flowy-folder2'; export class UserBackendService { @@ -56,8 +51,8 @@ export class UserBackendService { return UserEventUpdateUserProfile(payload); }; - getCurrentWorkspace = async (): Promise<WorkspaceSettingPB> => { - const result = await FolderEventGetCurrentWorkspace(); + getCurrentWorkspaceSetting = async (): Promise<WorkspaceSettingPB> => { + const result = await FolderEventGetCurrentWorkspaceSetting(); if (result.ok) { return result.val; @@ -67,15 +62,7 @@ export class UserBackendService { }; getWorkspaces = () => { - const payload = WorkspaceIdPB.fromObject({}); - - return FolderEventReadAllWorkspaces(payload); - }; - - openWorkspace = (workspaceId: string) => { - const payload = WorkspaceIdPB.fromObject({ value: workspaceId }); - - return FolderEventOpenWorkspace(payload); + return FolderEventReadCurrentWorkspace(); }; createWorkspace = async (params: { name: string; desc: string }): Promise<WorkspacePB> => { @@ -115,9 +102,14 @@ export class AuthBackendService { return UserEventSignIn(payload); }; - signUp = (params: { name: string; email: string; password: string; }) => { + signUp = (params: { name: string; email: string; password: string }) => { const deviceId = nanoid(8); - const payload = SignUpPayloadPB.fromObject({ name: params.name, email: params.email, password: params.password, device_id: deviceId }); + const payload = SignUpPayloadPB.fromObject({ + name: params.name, + email: params.email, + password: params.password, + device_id: deviceId, + }); return UserEventSignUp(payload); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_controller.ts index 5bdea28e3915..95d0a2a5b05b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_controller.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_controller.ts @@ -11,8 +11,8 @@ export class PageController { // } - dispose = () => { - this.observer.unsubscribe(); + dispose = async () => { + await this.observer.unsubscribe(); }; createPage = async (params: { name: string; layout: ViewLayoutPB }): Promise<string> => { @@ -56,7 +56,6 @@ export class PageController { getPage = async (id?: string): Promise<Page> => { const result = await this.backendService.getPage(id || this.id); - if (result.ok) { return parserViewPBToPage(result.val); } @@ -76,8 +75,10 @@ export class PageController { const res = ViewPB.deserializeBinary(payload); const page = parserViewPBToPage(ViewPB.deserializeBinary(payload)); const childPages = res.child_views.map(parserViewPBToPage); + callbacks.onPageChanged?.(page, childPages); }; + await this.observer.subscribeView(this.id, { didUpdateView, }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/trash/controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/trash/controller.ts index 5b84faa37c6a..86563a0ab7a8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/trash/controller.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/trash/controller.ts @@ -7,20 +7,20 @@ export class TrashController { private readonly backendService: TrashBackendService = new TrashBackendService(); - subscribe = (callbacks: { onTrashChanged?: (trash: TrashPB[]) => void }) => { + subscribe = async (callbacks: { onTrashChanged?: (trash: TrashPB[]) => void }) => { const didUpdateTrash = (payload: Uint8Array) => { const res = RepeatedTrashPB.deserializeBinary(payload); callbacks.onTrashChanged?.(res.items); }; - this.observer.subscribeTrash({ + await this.observer.subscribeTrash({ didUpdateTrash, }); }; - dispose = () => { - this.observer.unsubscribe(); + dispose = async () => { + await this.observer.unsubscribe(); }; getTrash = async () => { const res = await this.backendService.getTrash(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_bd_svc.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_bd_svc.ts index aab16ba0dcd6..1a5025412e4a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_bd_svc.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_bd_svc.ts @@ -1,13 +1,12 @@ import { FolderEventCreateWorkspace, - FolderEventGetCurrentWorkspace, CreateWorkspacePayloadPB, - FolderEventReadAllWorkspaces, - FolderEventOpenWorkspace, FolderEventDeleteWorkspace, WorkspaceIdPB, FolderEventReadWorkspaceViews, + FolderEventReadCurrentWorkspace, } from '@/services/backend/events/flowy-folder2'; +import { UserEventOpenWorkspace, UserWorkspaceIdPB } from '@/services/backend/events/flowy-user'; export class WorkspaceBackendService { constructor() { @@ -25,11 +24,11 @@ export class WorkspaceBackendService { }; openWorkspace = async (workspaceId: string) => { - const payload = new WorkspaceIdPB({ - value: workspaceId, + const payload = new UserWorkspaceIdPB({ + workspace_id: workspaceId, }); - return FolderEventOpenWorkspace(payload); + return UserEventOpenWorkspace(payload); }; deleteWorkspace = async (workspaceId: string) => { @@ -41,14 +40,11 @@ export class WorkspaceBackendService { }; getWorkspaces = async () => { - // if workspaceId is not provided, it will return all workspaces - const workspaceId = new WorkspaceIdPB(); - - return FolderEventReadAllWorkspaces(workspaceId); + return FolderEventReadCurrentWorkspace(); }; getCurrentWorkspace = async () => { - return FolderEventGetCurrentWorkspace(); + return FolderEventReadCurrentWorkspace(); }; getChildPages = async (workspaceId: string) => { diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_controller.ts index 9ff2856bfb66..56b7003c422e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_controller.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_controller.ts @@ -1,6 +1,6 @@ import { WorkspaceBackendService } from '$app/stores/effects/workspace/workspace_bd_svc'; import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer'; -import { CreateViewPayloadPB, RepeatedViewPB } from "@/services/backend"; +import { CreateViewPayloadPB, RepeatedViewPB } from '@/services/backend'; import { PageBackendService } from '$app/stores/effects/workspace/page/page_bd_svc'; import { Page, parserViewPBToPage } from '$app_reducers/pages/slice'; @@ -13,8 +13,8 @@ export class WorkspaceController { this.backendService = new WorkspaceBackendService(); } - dispose = () => { - this.observer.unsubscribe(); + dispose = async () => { + await this.observer.unsubscribe(); }; open = async () => { @@ -37,16 +37,15 @@ export class WorkspaceController { return Promise.reject(result.err); }; - subscribe = async (callbacks: { - onChildPagesChanged?: (childPages: Page[]) => void; - }) => { - + subscribe = async (callbacks: { onChildPagesChanged?: (childPages: Page[]) => void }) => { const didUpdateWorkspace = (payload: Uint8Array) => { const res = RepeatedViewPB.deserializeBinary(payload).items; + callbacks.onChildPagesChanged?.(res.map(parserViewPBToPage)); - } + }; + await this.observer.subscribeWorkspace(this.workspaceId, { - didUpdateWorkspace + didUpdateWorkspace, }); }; @@ -71,6 +70,4 @@ export class WorkspaceController { return []; }; - - } diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_manager_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_manager_controller.ts index 4e5becd343f2..67bfdd92eedf 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_manager_controller.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_manager_controller.ts @@ -1,5 +1,5 @@ import { WorkspaceBackendService } from './workspace_bd_svc'; -import { CreateWorkspacePayloadPB, RepeatedWorkspacePB } from '@/services/backend'; +import { CreateWorkspacePayloadPB } from '@/services/backend'; import { WorkspaceItem } from '$app_reducers/workspace/slice'; import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer'; @@ -33,14 +33,14 @@ export class WorkspaceManagerController { const result = await this.backendService.getWorkspaces(); if (result.ok) { - const items = result.val.items; + const item = result.val; - return items.map((item) => { - return { + return [ + { id: item.id, name: item.name, - }; - }); + }, + ]; } return []; @@ -50,7 +50,7 @@ export class WorkspaceManagerController { const result = await this.backendService.getCurrentWorkspace(); if (result.ok) { - const workspace = result.val.workspace; + const workspace = result.val; return { id: workspace.id, @@ -65,8 +65,8 @@ export class WorkspaceManagerController { await this.observer.unsubscribe(); }; - private didCreateWorkspace = (payload: Uint8Array) => { - const data = RepeatedWorkspacePB.deserializeBinary(payload); + private didCreateWorkspace = () => { + // const data = RepeatedWorkspacePB.deserializeBinary(payload); // onWorkspacesChanged(data.toObject().items); }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/slice.ts index f5c946209570..123225a64de2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/slice.ts @@ -81,17 +81,21 @@ export const blockDraggableSlice = createSlice({ state.draggingPosition = draggingPosition; state.dropContext = dropContext; - const moveDistance = Math.sqrt( - Math.pow(draggingPosition.x - state.startDraggingPosition!.x, 2) + - Math.pow(draggingPosition.y - state.startDraggingPosition!.y, 2) - ); + const { startDraggingPosition } = state; + + const moveDistance = startDraggingPosition + ? Math.sqrt( + Math.pow(draggingPosition.x - startDraggingPosition.x, 2) + + Math.pow(draggingPosition.y - startDraggingPosition.y, 2) + ) + : 0; state.dropId = dropId; state.insertType = insertType; state.dragShadowVisible = moveDistance > DRAG_DISTANCE_THRESHOLD; }, - endDrag: (state) => { + endDrag: () => { return initialState; }, }, diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/current-user/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/current-user/slice.ts index f7a245ad60d2..267c3e68f130 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/current-user/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/current-user/slice.ts @@ -1,5 +1,4 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { nanoid } from 'nanoid'; import { WorkspaceSettingPB } from '@/services/backend/models/flowy-folder2/workspace'; import { UserSetting } from '$app/interfaces'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts index cb6334aaf755..7a95846059fa 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts @@ -21,7 +21,7 @@ export const duplicateBelowNodeThunk = createAsyncThunk( if (!duplicateActions) return; await controller.applyActions(duplicateActions.actions); - dispatch( + await dispatch( setRectSelectionThunk({ docId, selection: [duplicateActions.newNodeId], diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts index 76cdca31ba20..5f19743be3d2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts @@ -14,7 +14,7 @@ export const insertAfterNodeThunk = createAsyncThunk( id: string; controller: DocumentController; type: BlockType; - data?: BlockData<any>; + data?: BlockData; defaultDelta?: Delta; }, thunkAPI @@ -22,8 +22,7 @@ export const insertAfterNodeThunk = createAsyncThunk( const { controller, id, type, data, defaultDelta } = payload; const { getState } = thunkAPI; const state = getState() as RootState; - const docId = controller.documentId; - const documentState = state[DOCUMENT_NAME][docId]; + const documentState = state[DOCUMENT_NAME][controller.documentId]; const node = documentState.nodes[id]; if (!node) return; @@ -34,8 +33,9 @@ export const insertAfterNodeThunk = createAsyncThunk( const actions = []; let newNodeId; const deltaOperator = new BlockDeltaOperator(documentState, controller); + if (type === BlockType.DividerBlock) { - const newNode = newBlock<any>(type, parentId, data); + const newNode = newBlock(type, parentId, data); actions.push(controller.getInsertAction(newNode, node.id)); newNodeId = newNode.id; @@ -64,7 +64,7 @@ export const insertAfterNodeThunk = createAsyncThunk( }) ); } else { - const newNode = newBlock<any>(type, parentId, data); + const newNode = newBlock(type, parentId, data); actions.push(controller.getInsertAction(newNode, node.id)); newNodeId = newNode.id; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts index 72f68e1eddad..6e211cc72810 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts @@ -8,7 +8,7 @@ import { updatePageName } from '$app_reducers/pages/async_actions'; import { getDeltaText } from '$app/utils/document/delta'; import { BlockDeltaOperator } from '$app/utils/document/block_delta'; import { openMention, closeMention } from '$app_reducers/document/async-actions/mention'; -import {slashCommandActions} from "$app_reducers/document/slice"; +import { slashCommandActions } from '$app_reducers/document/slice'; const updateNodeDeltaAfterThunk = createAsyncThunk( 'document/updateNodeDeltaAfter', @@ -27,9 +27,11 @@ const updateNodeDeltaAfterThunk = createAsyncThunk( if (insertOps.length === 1) { const char = insertOps[0].insert; + if (char === '@' && (oldText.endsWith(' ') || oldText === '')) { - dispatch(openMention({ docId })); + await dispatch(openMention({ docId })); } + if (char === '/') { dispatch( slashCommandActions.openSlashCommand({ @@ -42,15 +44,13 @@ const updateNodeDeltaAfterThunk = createAsyncThunk( if (deleteOps.length === 1) { if (deleteText === '@') { - dispatch(closeMention({ docId })); + await dispatch(closeMention({ docId })); } + if (deleteText === '/') { - dispatch( - slashCommandActions.closeSlashCommand(docId) - ); + dispatch(slashCommandActions.closeSlashCommand(docId)); } } - } ); @@ -92,7 +92,7 @@ export const updateNodeDataThunk = createAsyncThunk< void, { id: string; - data: Partial<BlockData<any>>; + data: Partial<BlockData>; controller: DocumentController; } >('document/updateNodeDataExceptDelta', async (payload, thunkAPI) => { diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copy_paste.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copy_paste.ts index 1c7b7d10bfac..97392462ffc2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copy_paste.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copy_paste.ts @@ -9,7 +9,7 @@ export const copyThunk = createAsyncThunk< controller: DocumentController; setClipboardData: (data: BlockCopyData) => void; } ->('document/copy', async (payload, thunkAPI) => { +>('document/copy', async () => { // TODO: Migrate to Rust implementation. }); @@ -29,6 +29,6 @@ export const pasteThunk = createAsyncThunk< data: BlockCopyData; controller: DocumentController; } ->('document/paste', async (payload, thunkAPI) => { +>('document/paste', async () => { // TODO: Migrate to Rust implementation. }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts index 343001d88e7f..449438b559ce 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts @@ -71,7 +71,7 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk( length: 0, }; - dispatch( + await dispatch( setCursorRangeThunk({ docId, blockId: caret.id, @@ -115,7 +115,6 @@ export const enterActionForBlockThunk = createAsyncThunk( ); }); const isDocumentTitle = !node.parent; - let newLineId; const delta = deltaOperator.getDeltaWithBlockId(node.id); @@ -126,7 +125,7 @@ export const enterActionForBlockThunk = createAsyncThunk( return; } - newLineId = await deltaOperator.splitText( + const newLineId = await deltaOperator.splitText( { id: node.id, index: caret.index, @@ -138,7 +137,7 @@ export const enterActionForBlockThunk = createAsyncThunk( ); if (!newLineId) return; - dispatch( + await dispatch( setCursorRangeThunk({ docId, blockId: newLineId, diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/mention.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/mention.ts index 4ec94ea731df..f9379a1b7846 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/mention.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/mention.ts @@ -70,11 +70,15 @@ export const formatMention = createAsyncThunk( const nodeDelta = deltaOperator.getDeltaWithBlockId(blockId); if (!nodeDelta) return; - const diffDelta = new Delta().retain(index).delete(charLength).insert('@',{ mention: { type, [type]: value } }); + const diffDelta = new Delta() + .retain(index) + .delete(charLength) + .insert('@', { mention: { type, [type]: value } }); const applyTextDeltaAction = deltaOperator.getApplyDeltaAction(blockId, diffDelta); + if (!applyTextDeltaAction) return; await controller.applyActions([applyTextDeltaAction]); - dispatch( + await dispatch( setCursorRangeThunk({ docId, blockId, diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts index 7c75ea6a771a..1f1f50321c57 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts @@ -1,15 +1,10 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; -import { BlockData, BlockType } from '$app/interfaces/document'; +import { BlockType } from '$app/interfaces/document'; import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks'; import { DocumentController } from '$app/stores/effects/document/document_controller'; import { rangeActions, slashCommandActions } from '$app_reducers/document/slice'; -import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to'; -import { blockConfig } from '$app/constants/document/config'; -import Delta, { Op } from 'quill-delta'; -import { getDeltaText } from '$app/utils/document/delta'; +import Delta from 'quill-delta'; import { RootState } from '$app/stores/store'; -import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name'; -import { blockEditActions } from '$app_reducers/document/block_edit_slice'; import { BlockDeltaOperator } from '$app/utils/document/block_delta'; /** diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts index fb980d16cd95..14b8e1e00d7f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts @@ -156,7 +156,7 @@ export const deleteRangeAndInsertThunk = createAsyncThunk( ); if (!id) return; - dispatch( + await dispatch( setCursorRangeThunk({ docId, blockId: id, @@ -211,7 +211,7 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk( ); if (!newLineId) return; - dispatch( + await dispatch( setCursorRangeThunk({ docId, blockId: newLineId, diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/temporary.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/temporary.ts index fbc33dc73929..7864c6638725 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/temporary.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/temporary.ts @@ -1,7 +1,6 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { RootState } from '$app/stores/store'; import { DOCUMENT_NAME, EQUATION_PLACEHOLDER, RANGE_NAME, TEMPORARY_NAME } from '$app/constants/document/name'; -import { getDeltaByRange, getDeltaText } from '$app/utils/document/delta'; import Delta from 'quill-delta'; import { TemporaryState, TemporaryType } from '$app/interfaces/document'; import { temporaryActions } from '$app_reducers/document/temporary_slice'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts index bdfd1d4fde34..2c9e9939db28 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts @@ -20,7 +20,7 @@ import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name'; */ export const turnToBlockThunk = createAsyncThunk( 'document/turnToBlock', - async (payload: { id: string; controller: DocumentController; type: BlockType; data: BlockData<any> }, thunkAPI) => { + async (payload: { id: string; controller: DocumentController; type: BlockType; data: BlockData }, thunkAPI) => { const { id, controller, type, data } = payload; const docId = controller.documentId; const { dispatch, getState } = thunkAPI; @@ -36,7 +36,7 @@ export const turnToBlockThunk = createAsyncThunk( let caretId, caretIndex = caret?.index || 0; const deltaOperator = new BlockDeltaOperator(documentState, controller); - let delta = deltaOperator.getDeltaWithBlockId(node.id); + let delta = deltaOperator.getDeltaWithBlockId(node.id) || new Delta([{ insert: '' }]); // insert new block after current block const insertActions = []; @@ -44,52 +44,63 @@ export const turnToBlockThunk = createAsyncThunk( delta = new Delta([{ insert: node.data.formula }]); } - if (delta && type === BlockType.EquationBlock) { - data.formula = deltaOperator.getDeltaText(delta); - const block = newBlock<any>(type, parent.id, data); - - insertActions.push(controller.getInsertAction(block, node.id)); - caretId = block.id; - caretIndex = 0; - } else if (delta && type === BlockType.DividerBlock) { - const block = newBlock<any>(type, parent.id, data); - - insertActions.push(controller.getInsertAction(block, node.id)); - const nodeId = generateId(); - const actions = deltaOperator.getNewTextLineActions({ - blockId: nodeId, - parentId: parent.id, - prevId: block.id || null, - delta: delta ? delta : new Delta([{ insert: '' }]), - type: BlockType.TextBlock, - data, - }); + const block = newBlock(type, parent.id, data); - caretId = nodeId; - caretIndex = 0; - insertActions.push(...actions); - } else if (delta) { - caretId = generateId(); + caretId = block.id; - const actions = deltaOperator.getNewTextLineActions({ - blockId: caretId, - parentId: parent.id, - prevId: node.id, - delta: delta, - type, - data, - }); - - insertActions.push(...actions); + switch (type) { + case BlockType.GridBlock: + insertActions.push(controller.getInsertAction(block, node.id)); + caretIndex = 0; + break; + case BlockType.EquationBlock: + data.formula = deltaOperator.getDeltaText(delta); + insertActions.push(controller.getInsertAction(block, node.id)); + caretIndex = 0; + break; + case BlockType.DividerBlock: { + insertActions.push(controller.getInsertAction(block, node.id)); + + const nodeId = generateId(); + + caretId = nodeId; + caretIndex = 0; + insertActions.push( + ...deltaOperator.getNewTextLineActions({ + blockId: nodeId, + parentId: parent.id, + prevId: block.id || null, + delta: delta ? delta : new Delta([{ insert: '' }]), + type: BlockType.TextBlock, + data, + }) + ); + break; + } + + default: + caretId = generateId(); + + insertActions.push( + ...deltaOperator.getNewTextLineActions({ + blockId: caretId, + parentId: parent.id, + prevId: node.id, + delta, + type, + data, + }) + ); + break; } if (!caretId) return; // check if prev node is allowed to have children const config = blockConfig[type]; // if new block is not allowed to have children, move children to parent - const newParentId = config.canAddChild ? caretId : parent.id; + const newParentId = config?.canAddChild ? caretId : parent.id; // if move children to parent, set prev to current block, otherwise the prev is empty - const newPrev = config.canAddChild ? null : caretId; + const newPrev = config?.canAddChild ? null : caretId; const moveChildrenActions = controller.getMoveChildrenAction(children, newParentId, newPrev); // delete current block @@ -97,7 +108,7 @@ export const turnToBlockThunk = createAsyncThunk( // submit actions await controller.applyActions([...insertActions, ...moveChildrenActions, deleteAction]); - dispatch( + await dispatch( setCursorRangeThunk({ docId, blockId: caretId, diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts index 08aed7199031..901a7a65ebe7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts @@ -58,35 +58,17 @@ export const movePageThunk = createAsyncThunk( } ); -export const updatePageName = createAsyncThunk( - 'pages/updateName', - async ( - payload: { - id: string; - name: string; - }, - thunkAPI - ) => { - const controller = new PageController(payload.id); +export const updatePageName = createAsyncThunk('pages/updateName', async (payload: { id: string; name: string }) => { + const controller = new PageController(payload.id); - await controller.updatePage({ - id: payload.id, - name: payload.name, - }); - } -); + await controller.updatePage({ + id: payload.id, + name: payload.name, + }); +}); -export const updatePageIcon = createAsyncThunk( - 'pages/updateIcon', - async ( - payload: { - id: string; - icon?: PageIcon; - }, - thunkAPI - ) => { - const controller = new PageController(payload.id); +export const updatePageIcon = createAsyncThunk('pages/updateIcon', async (payload: { id: string; icon?: PageIcon }) => { + const controller = new PageController(payload.id); - await controller.updatePageIcon(payload.icon); - } -); + await controller.updatePageIcon(payload.icon); +}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts index 1aa0c4aa79f8..5d7d63e003fa 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts @@ -88,8 +88,10 @@ export const pagesSlice = createSlice({ expandPage(state, action: PayloadAction<string>) { const id = action.payload; + state.expandedIdMap[id] = true; const ids = Object.keys(state.expandedIdMap).filter(id => state.expandedIdMap[id]); + storeExpandedPageIds(ids); }, @@ -98,6 +100,7 @@ export const pagesSlice = createSlice({ state.expandedIdMap[id] = false; const ids = Object.keys(state.expandedIdMap).filter(id => state.expandedIdMap[id]); + storeExpandedPageIds(ids); }, }, diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/async_queue.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/async_queue.ts index a058032fed84..7e673506de0b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/async_queue.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/async_queue.ts @@ -21,6 +21,10 @@ export class AsyncQueue<T = unknown> { const item = this.queue.shift(); + if (!item) { + return; + } + this.isProcessing = true; const executeFn = async (item: T) => { @@ -34,7 +38,7 @@ export class AsyncQueue<T = unknown> { } }; - executeFn(item!); + void executeFn(item); } private async processItem(item: T): Promise<void> { diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/change_notifier.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/change_notifier.ts index df81d11c4799..57d9f2a3704c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/change_notifier.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/change_notifier.ts @@ -12,6 +12,7 @@ export class ChangeNotifier<T> { if (this.isUnsubscribe) { return null; } + return this.subject.asObservable(); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts index 8921e2b4d83a..31522469990a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts @@ -1,29 +1,14 @@ -import { - BlockType, - ControllerAction, - DocumentState, - NestedBlock, - RangeState, - RangeStatic, - SplitRelationship, -} from '$app/interfaces/document'; -import { getNextLineId, getPrevLineId, newBlock } from '$app/utils/document/block'; -import Delta from 'quill-delta'; -import { RootState } from '$app/stores/store'; +import { ControllerAction, DocumentState, RangeState, RangeStatic } from '$app/interfaces/document'; +import { getNextLineId, newBlock } from '$app/utils/document/block'; import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { blockConfig } from '$app/constants/document/config'; import { caretInBottomEdgeByDelta, caretInTopEdgeByDelta, - getAfterExtentDeltaByRange, - getBeofreExtentDeltaByRange, - getDeltaText, getIndexRelativeEnter, getLastLineIndex, transformIndexToNextLine, transformIndexToPrevLine, } from '$app/utils/document/delta'; -import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name'; import { BlockDeltaOperator } from '$app/utils/document/block_delta'; export function getMiddleIds(document: DocumentState, startId: string, endId: string) { @@ -80,7 +65,7 @@ export function getLeftCaretByRange(rangeState: RangeState) { } export function getRightCaretByRange(rangeState: RangeState) { - const { anchor, focus, ranges, caret } = rangeState; + const { anchor, focus, ranges } = rangeState; if (!anchor || !focus) return; const isForward = anchor.point.y < focus.point.y; @@ -180,7 +165,7 @@ export function getDuplicateActions( if (!node) return; // duplicate new node - const newNode = newBlock<any>(node.type, parentId, { + const newNode = newBlock(node.type, parentId, { ...node.data, }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/block.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/block.ts index 18734e47358c..9b231756a8c3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/block.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/block.ts @@ -100,11 +100,13 @@ export function getPrevNodeId(state: DocumentState, id: string) { } export function newBlock<Type>(type: BlockType, parentId: string, data?: BlockData<Type>): NestedBlock<Type> { + const blockData = data || ({} as BlockData<Type>); + return { id: generateId(), type, parent: parentId, children: generateId(), - data: data ? data : {}, + data: blockData, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/block_delta.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/block_delta.ts index cef85413d8fd..4b10d9c9f65e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/block_delta.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/block_delta.ts @@ -1,4 +1,4 @@ -import { BlockData, BlockType, DocumentState, NestedBlock, SplitRelationship } from '$app/interfaces/document'; +import { BlockData, BlockType, DocumentState, SplitRelationship } from '$app/interfaces/document'; import { generateId, getNextLineId, getPrevLineId } from '$app/utils/document/block'; import { DocumentController } from '$app/stores/effects/document/document_controller'; import Delta, { Op } from 'quill-delta'; @@ -92,7 +92,7 @@ export class BlockDeltaOperator { parentId: string; type: BlockType; prevId: string | null; - data?: BlockData<any>; + data?: BlockData; }) => { const externalId = generateId(); const block = { diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/format.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/format.ts index d156b6a06615..66fe95caaf85 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/format.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/format.ts @@ -24,5 +24,6 @@ export function parseFormat(e: KeyboardEvent | React.KeyboardEvent<HTMLDivElemen } else if (isHotkey(Keyboard.keys.FORMAT.CODE, e)) { return TextAction.Code; } + return null; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts index d50cb04b2b35..5d7dc8a565aa 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts @@ -25,8 +25,8 @@ export function findFirstTextNode(node: Node): Node | null { const children = node.childNodes; - for (let i = 0; i < children.length; i++) { - const textNode = findFirstTextNode(children[i]); + for (const child of children) { + const textNode = findFirstTextNode(child); if (textNode) { return textNode; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/quill_editor.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/quill_editor.ts index e29c0fd72d02..7b9690b89454 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/quill_editor.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/quill_editor.ts @@ -46,6 +46,7 @@ export function adaptDeltaForQuill(inputOps: Op[], isOutput = false): Op[] { if (isOutput) { const newText = text.slice(0, -1); + if (newText !== '') { newOps[lastOpIndex] = { ...lastOp, insert: newText }; } else { diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts index 2f8cfac126cd..619dcf06f017 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts @@ -107,6 +107,7 @@ export function convertToSlateValue(delta: Delta): Descendant[] { export function convertToDelta(slateValue: Descendant[]) { const ops = (slateValue[0] as Element).children.map((child) => { const { text, ...attributes } = child as Text; + return { insert: text, attributes, diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts index 93546f33b239..fa8318ac4226 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts @@ -1,6 +1,7 @@ export function calcToolbarPosition(toolbarDom: HTMLDivElement, node: Element, container: HTMLDivElement) { const domSelection = window.getSelection(); let domRange; + if (domSelection?.rangeCount === 0) { return; } else { @@ -18,6 +19,7 @@ export function calcToolbarPosition(toolbarDom: HTMLDivElement, node: Element, c const rightBound = containerRect.right; const rightThreshold = 20; + if (left < leftBound) { left = leftBound; } else if (left + nodeRect.left + toolbarDom.offsetWidth > rightBound) { diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/draggable.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/draggable.ts index e0769899cadf..090ab871d7b9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/draggable.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/draggable.ts @@ -1,8 +1,6 @@ import { BlockDraggableType, DragInsertType } from '$app_reducers/block-draggable/slice'; import { findParent } from '$app/utils/document/node'; import { nanoid } from 'nanoid'; -import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { blockConfig } from '$app/constants/document/config'; export function getDraggableIdByPoint(target: HTMLElement | null) { let node = target; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/region_grid.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/region_grid.ts index 3922c67329d1..75129c3bea7c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/region_grid.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/region_grid.ts @@ -46,7 +46,7 @@ export class RegionGrid { this.grid.set(key, []); } - this.grid.get(key)!.push(block); + this.grid.get(key)?.push(block); } } @@ -58,6 +58,7 @@ export class RegionGrid { if (this.hasBlock(block.id)) { this.removeBlock(block); } + this.addBlock(block); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts index de291b980d9e..3f408738de70 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + export function debounce(fn: (...args: any[]) => void, delay: number) { let timeout: NodeJS.Timeout; const debounceFn = (...args: any[]) => { @@ -17,7 +19,7 @@ export function debounce(fn: (...args: any[]) => void, delay: number) { export function throttle<T extends (...args: any[]) => void = (...args: any[]) => void>( fn: T, delay: number, - immediate = true, + immediate = true ): T { let timeout: NodeJS.Timeout | null = null; @@ -156,7 +158,7 @@ export function chunkArray<T>(array: T[], chunkSize: number) { export function interval<T extends (...args: any[]) => any = (...args: any[]) => any>( fn: T, delay?: number, - options?: { immediate?: boolean }, + options?: { immediate?: boolean } ): T & { cancel: () => void } { const { immediate = true } = options || {}; let intervalId: NodeJS.Timer | null = null; diff --git a/frontend/appflowy_tauri/src/appflowy_app/views/DatabasePage.tsx b/frontend/appflowy_tauri/src/appflowy_app/views/DatabasePage.tsx index 61f86d22f04f..1b14c939815c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/views/DatabasePage.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/views/DatabasePage.tsx @@ -1,5 +1,24 @@ -import { Database } from '../components/database'; +import { useParams } from 'react-router-dom'; +import { ViewIdProvider } from '$app/hooks'; +import { Database, DatabaseTitle, useSelectDatabaseView } from '../components/database'; export const DatabasePage = () => { - return <Database />; + const viewId = useParams().id; + + const { selectedViewId, onChange } = useSelectDatabaseView({ + viewId, + }); + + if (!viewId) { + return null; + } + + return ( + <div className='flex h-full w-full flex-col overflow-hidden px-16 caret-text-title'> + <ViewIdProvider value={viewId}> + <DatabaseTitle /> + <Database selectedViewId={selectedViewId} setSelectedViewId={onChange} /> + </ViewIdProvider> + </div> + ); }; diff --git a/frontend/appflowy_tauri/src/styles/template.css b/frontend/appflowy_tauri/src/styles/template.css index c23eaad52b26..cabbdfb23166 100644 --- a/frontend/appflowy_tauri/src/styles/template.css +++ b/frontend/appflowy_tauri/src/styles/template.css @@ -23,6 +23,11 @@ body { @apply bg-content-blue-100; } +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + div[role="textbox"] ::selection { @apply bg-transparent; } diff --git a/frontend/resources/flowy_icons/16x/hamburger_s.svg b/frontend/resources/flowy_icons/16x/hamburger_s.svg new file mode 100644 index 000000000000..ae63919cce22 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/hamburger_s.svg @@ -0,0 +1,7 @@ +<svg width="6" height="8" viewBox="0 0 6 8" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="Vector"> +<path d="M0 0.799951C0 0.46858 0.268629 0.199951 0.6 0.199951H5.4C5.73137 0.199951 6 0.46858 6 0.799951C6 1.13132 5.73137 1.39995 5.4 1.39995H0.600001C0.26863 1.39995 0 1.13132 0 0.799951Z" fill="#8F959E"/> +<path d="M0 3.99995C0 3.66858 0.268629 3.39995 0.6 3.39995H5.4C5.73137 3.39995 6 3.66858 6 3.99995C6 4.33132 5.73137 4.59995 5.4 4.59995H0.600001C0.26863 4.59995 0 4.33132 0 3.99995Z" fill="#8F959E"/> +<path d="M0 7.19995C0 6.86858 0.268629 6.59995 0.6 6.59995H5.4C5.73137 6.59995 6 6.86858 6 7.19995C6 7.53132 5.73137 7.79995 5.4 7.79995H0.600001C0.26863 7.79995 0 7.53132 0 7.19995Z" fill="#8F959E"/> +</g> +</svg> diff --git a/frontend/resources/flowy_icons/16x/notes.svg b/frontend/resources/flowy_icons/16x/notes.svg new file mode 100644 index 000000000000..a6096ef23814 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/notes.svg @@ -0,0 +1,11 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="Icons 16 / docs"> +<g id="Group 1321314145"> +<path id="Vector" d="M9.08889 2V5.81818C9.08889 6.42067 9.5764 6.90909 10.1778 6.90909H12.3556M4.18889 14H11.8111C12.4125 14 12.9 13.5116 12.9 12.9091V7.09091C12.9 6.61883 12.7472 6.15948 12.4644 5.78182L10.2867 2.87273C9.87538 2.32333 9.22991 2 8.54444 2H4.18889C3.58751 2 3.1 2.48842 3.1 3.09091V12.9091C3.1 13.5116 3.58751 14 4.18889 14Z" stroke="#747B84" stroke-width="1.09091"/> +<g id="Vector_2"> +<path d="M5.27777 10.7273C4.97708 10.7273 4.73332 10.9715 4.73332 11.2727C4.73332 11.574 4.97708 11.8182 5.27777 11.8182H7.45554C7.75623 11.8182 7.99999 11.574 7.99999 11.2727C7.99999 10.9715 7.75623 10.7273 7.45554 10.7273H5.27777Z" fill="#747B84"/> +<path d="M4.73332 9.09091C4.73332 8.78966 4.97708 8.54546 5.27777 8.54546H9.63332C9.93401 8.54546 10.1778 8.78966 10.1778 9.09091C10.1778 9.39216 9.93401 9.63637 9.63332 9.63637H5.27777C4.97708 9.63637 4.73332 9.39216 4.73332 9.09091Z" fill="#747B84"/> +</g> +</g> +</g> +</svg> diff --git a/frontend/resources/flowy_icons/16x/pull_left_outlined.svg b/frontend/resources/flowy_icons/16x/pull_left_outlined.svg new file mode 100644 index 000000000000..a1cd5c0e1be7 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/pull_left_outlined.svg @@ -0,0 +1,6 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="icon_pull-left_outlined"> +<path id="Union" d="M14.6515 9.58019H9.1031L10.7447 7.91716C10.9061 7.75372 10.9048 7.4864 10.7435 7.3229C10.5825 7.1597 10.3193 7.15862 10.1582 7.32177L7.66078 9.85165C7.62187 9.89109 7.60001 9.94456 7.60001 10.0003C7.60001 10.0561 7.62187 10.1095 7.66078 10.149L10.1566 12.6773C10.3184 12.8412 10.5808 12.8407 10.7426 12.6767C10.9046 12.5125 10.9053 12.2461 10.7432 12.082L9.10373 10.4213H14.6515C14.8808 10.4213 15.0667 10.233 15.0667 10.0007C15.0667 9.76848 14.8808 9.58019 14.6515 9.58019Z" fill="#8F959E" stroke="#8F959E" stroke-width="0.1" stroke-linecap="round"/> +<path id="Union_2" d="M5.19995 14.1126C5.19995 14.437 5.42384 14.7 5.69995 14.7C5.97606 14.7 6.19995 14.437 6.19995 14.1126V5.88753C6.19995 5.56307 5.97606 5.30005 5.69995 5.30005C5.42384 5.30005 5.19995 5.56307 5.19995 5.88753V14.1126Z" fill="#8F959E"/> +</g> +</svg> diff --git a/frontend/resources/flowy_icons/24x/close_filled.svg b/frontend/resources/flowy_icons/24x/close_filled.svg new file mode 100644 index 000000000000..e6dffe953a10 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/close_filled.svg @@ -0,0 +1,3 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15ZM10.1392 5.14768C10.3361 4.95077 10.6554 4.95077 10.8523 5.14768C11.0492 5.34459 11.0492 5.66385 10.8523 5.86076L8.71303 8L10.8523 10.1392C11.0492 10.3362 11.0492 10.6554 10.8523 10.8523C10.6554 11.0492 10.3361 11.0492 10.1392 10.8523L7.99996 8.71307L5.86076 10.8523C5.66385 11.0492 5.34459 11.0492 5.14768 10.8523C4.95077 10.6554 4.95077 10.3361 5.14768 10.1392L7.28688 8L5.14769 5.8608C4.95078 5.66389 4.95078 5.34464 5.14769 5.14773C5.3446 4.95082 5.66385 4.95082 5.86076 5.14773L7.99996 7.28692L10.1392 5.14768Z" fill="#1F2329" fill-opacity="0.4"/> +</svg> diff --git a/frontend/resources/flowy_icons/32x/m_toolbar_imae.svg b/frontend/resources/flowy_icons/32x/m_toolbar_imae.svg new file mode 100644 index 000000000000..e694a1bc7cdb --- /dev/null +++ b/frontend/resources/flowy_icons/32x/m_toolbar_imae.svg @@ -0,0 +1,3 @@ +<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M7.49805 2.99072C5.28905 2.99072 3.49805 4.78172 3.49805 6.99072V15.9907V16.9907C3.49805 19.1997 5.28905 20.9907 7.49805 20.9907H17.498C19.707 20.9907 21.498 19.1997 21.498 16.9907V15.9907V6.99072C21.498 4.78172 19.707 2.99072 17.498 2.99072H7.49805ZM7.49805 4.99072H17.498C18.603 4.99072 19.498 5.88572 19.498 6.99072L19.502 13.1867C18.724 12.4647 17.9311 12.0127 17.0601 11.9907C17.0291 11.9897 17.03 11.9907 16.998 11.9907C15.861 11.9907 14.597 12.8647 13.748 13.9597C13.531 13.4697 13.3121 12.9857 13.0601 12.5217C11.8801 10.3427 10.591 8.99372 8.99805 8.99072C7.64405 8.98772 6.48205 10.0947 5.49005 11.6107L5.49805 6.99072C5.49805 5.88572 6.39305 4.99072 7.49805 4.99072ZM16.498 6.99072C15.946 6.99072 15.498 7.43872 15.498 7.99072C15.498 8.54272 15.946 8.99072 16.498 8.99072C17.05 8.99072 17.498 8.54272 17.498 7.99072C17.498 7.43872 17.05 6.99072 16.498 6.99072ZM8.99805 10.9907C9.58005 10.9917 10.4641 11.8977 11.3101 13.4597C11.6461 14.0777 11.947 14.7587 12.217 15.4287C12.379 15.8287 12.5041 16.1347 12.5601 16.3027C12.8331 17.1197 13.941 17.2357 14.373 16.4907C14.416 16.4167 14.494 16.2747 14.623 16.0847C14.839 15.7647 15.085 15.4407 15.342 15.1467C15.984 14.4107 16.603 13.9907 16.998 13.9907C17.397 14.0007 18.0131 14.4197 18.6541 15.1467C18.9141 15.4417 19.154 15.7647 19.373 16.0847C19.442 16.1847 19.452 16.2317 19.498 16.3027V16.9907C19.498 18.0957 18.603 18.9907 17.498 18.9907H7.49805C6.39305 18.9907 5.49805 18.0957 5.49805 16.9907V16.1467C5.56105 15.9687 5.64505 15.7577 5.77905 15.4287C6.05205 14.7587 6.34804 14.0787 6.68604 13.4597C7.53904 11.8947 8.41705 10.9897 8.99805 10.9907Z" fill="#676666"/> +</svg> diff --git a/frontend/resources/translations/ar-SA.json b/frontend/resources/translations/ar-SA.json index ceb98e4fde45..0196f6176995 100644 --- a/frontend/resources/translations/ar-SA.json +++ b/frontend/resources/translations/ar-SA.json @@ -159,9 +159,7 @@ "editContact": "تحرير جهة الاتصال" }, "button": { - "OK": "نعم", - "Done": "منتهي", - "Cancel": "إلغاء", + "done": "منتهي", "signIn": "تسجيل الدخول", "signOut": "خروج", "complete": "مكتمل", @@ -177,8 +175,10 @@ "edit": "يحرر", "delete": "يمسح", "duplicate": "ينسخ", - "done": "منتهي", - "putback": "ضعها بالخلف" + "putback": "ضعها بالخلف", + "OK": "نعم", + "Done": "منتهي", + "Cancel": "إلغاء" }, "label": { "welcome": "مرحباً!", @@ -547,11 +547,11 @@ } }, "board": { + "menuName": "سبورة", + "referencedBoardPrefix": "نظرا ل", "column": { "create_new_card": "جديد" - }, - "menuName": "سبورة", - "referencedBoardPrefix": "نظرا ل" + } }, "calendar": { "menuName": "تقويم", @@ -568,9 +568,9 @@ "firstDayOfWeek": "اليوم الأول من الأسبوع", "layoutDateField": "تقويم التخطيط بواسطة", "noDateTitle": "بدون تاريخ", - "noDateHint": "ستظهر الأحداث غير المجدولة هنا", "clickToAdd": "انقر للإضافة إلى التقويم", - "name": "تخطيط التقويم" + "name": "تخطيط التقويم", + "noDateHint": "ستظهر الأحداث غير المجدولة هنا" }, "referencedCalendarPrefix": "نظرا ل" }, diff --git a/frontend/resources/translations/ca-ES.json b/frontend/resources/translations/ca-ES.json index 1b26bf233970..29be400e8f42 100644 --- a/frontend/resources/translations/ca-ES.json +++ b/frontend/resources/translations/ca-ES.json @@ -159,9 +159,7 @@ "editContact": "Editar un contacte" }, "button": { - "OK": "OK", - "Done": "Fet", - "Cancel": "Cancel·lar", + "done": "Fet", "signIn": "Iniciar sessió", "signOut": "Tancar sessió", "complete": "Completar", @@ -177,8 +175,10 @@ "edit": "Edita", "delete": "Suprimeix", "duplicate": "Duplicat", - "done": "Fet", - "putback": "Posar enrere" + "putback": "Posar enrere", + "OK": "OK", + "Done": "Fet", + "Cancel": "Cancel·lar" }, "label": { "welcome": "Benvingut!", @@ -546,11 +546,11 @@ } }, "board": { + "menuName": "Pissarra", + "referencedBoardPrefix": "Vista de", "column": { "create_new_card": "Nou" - }, - "menuName": "Pissarra", - "referencedBoardPrefix": "Vista de" + } }, "calendar": { "menuName": "Calendari", @@ -567,9 +567,9 @@ "firstDayOfWeek": "Comença la setmana", "layoutDateField": "Disseny del calendari per", "noDateTitle": "Sense data", - "noDateHint": "Els esdeveniments no programats es mostraran aquí", "clickToAdd": "Feu clic per afegir al calendari", - "name": "Disseny del calendari" + "name": "Disseny del calendari", + "noDateHint": "Els esdeveniments no programats es mostraran aquí" }, "referencedCalendarPrefix": "Vista de" }, diff --git a/frontend/resources/translations/de-DE.json b/frontend/resources/translations/de-DE.json index 4fe04a27368c..2a39ae0bad0e 100644 --- a/frontend/resources/translations/de-DE.json +++ b/frontend/resources/translations/de-DE.json @@ -189,9 +189,7 @@ "editContact": "Kontakt bearbeiten" }, "button": { - "OK": "OK", - "Done": "Erledigt", - "Cancel": "Abbrechen", + "done": "Erledigt", "signIn": "Anmelden", "signOut": "Abmelden", "complete": "Fertig", @@ -207,8 +205,10 @@ "edit": "Bearbeiten", "delete": "Löschen", "duplicate": "Duplikat", - "done": "Erledigt", - "putback": "Zurück geben" + "putback": "Zurück geben", + "OK": "OK", + "Done": "Erledigt", + "Cancel": "Abbrechen" }, "label": { "welcome": "Willkommen!", @@ -591,11 +591,11 @@ } }, "board": { + "menuName": "Planke", + "referencedBoardPrefix": "Sicht von", "column": { "create_new_card": "Neu" - }, - "menuName": "Planke", - "referencedBoardPrefix": "Sicht von" + } }, "calendar": { "menuName": "Kalender", @@ -612,9 +612,9 @@ "firstDayOfWeek": "Beginnen Sie die Woche am", "layoutDateField": "Layoutkalender von", "noDateTitle": "Kein Datum", - "noDateHint": "Außerplanmäßige Ereignisse werden hier angezeigt", "clickToAdd": "Klicken Sie, um es zum Kalender hinzuzufügen", - "name": "Kalenderlayout" + "name": "Kalenderlayout", + "noDateHint": "Außerplanmäßige Ereignisse werden hier angezeigt" }, "referencedCalendarPrefix": "Sicht von" }, @@ -640,4 +640,4 @@ "deleteContentTitle": "Möchten Sie den {pageType} wirklich löschen?", "deleteContentCaption": "Wenn Sie diesen {pageType} löschen, können Sie ihn aus dem Papierkorb wiederherstellen." } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 8992e84d4db5..5ddeac2e0c3c 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -125,7 +125,9 @@ "mobile": { "actions": "Trash Actions", "empty": "Trash Bin is Empty", - "emptyDescription": "You don't have any deleted file" + "emptyDescription": "You don't have any deleted file", + "isDeleted": "is deleted", + "isRestored": "is restored" } }, "deletePagePrompt": { @@ -189,7 +191,8 @@ "favorites": "Favorites", "clickToHidePersonal": "Click to hide personal section", "clickToHideFavorites": "Click to hide favorite section", - "addAPage": "Add a page" + "addAPage": "Add a page", + "recent": "Recent" }, "notifications": { "export": { @@ -217,7 +220,8 @@ "tryAgain": "Try again", "discard": "Discard", "replace": "Replace", - "insertBelow": "Insert Below", + "insertBelow": "Insert below", + "insertAbove": "Insert above", "upload": "Upload", "edit": "Edit", "delete": "Delete", @@ -399,7 +403,9 @@ "support": "Support", "joinDiscord": "Join us in Discord", "privacyPolicy": "Privacy Policy", - "userAgreement": "User Agreement" + "userAgreement": "User Agreement", + "userprofileError": "Failed to load user profile", + "userprofileErrorDescription": "Please try to log out and log back in to check if the issue still persists." } }, "grid": { @@ -531,7 +537,10 @@ "newRow": "New row", "action": "Action", "add": "Click add to below", - "drag": "Drag to move" + "drag": "Drag to move", + "dragAndClick": "Drag to move, click to open menu", + "insertRecordAbove": "Insert record above", + "insertRecordBelow": "Insert record below" }, "selectOption": { "create": "Create", @@ -580,6 +589,9 @@ "calendar": { "selectACalendarToLinkTo": "Select a Calendar to link to", "createANewCalendar": "Create a new Calendar" + }, + "document": { + "selectADocumentToLinkTo": "Select a Document to link to" } }, "selectionMenu": { @@ -590,6 +602,7 @@ "referencedBoard": "Referenced Board", "referencedGrid": "Referenced Grid", "referencedCalendar": "Referenced Calendar", + "referencedDocument": "Referenced Document", "autoGeneratorMenuItemName": "OpenAI Writer", "autoGeneratorTitleName": "OpenAI: Ask AI to write anything...", "autoGeneratorLearnMore": "Learn more", @@ -609,7 +622,13 @@ "smartEditDisabled": "Connect OpenAI in Settings", "discardResponse": "Do you want to discard the AI responses?", "createInlineMathEquation": "Create equation", - "toggleList": "Toggle List", + "fonts": "Fonts", + "toggleList": "Toggle list", + "quoteList": "Quote list", + "numberedList": "Numbered list", + "bulletedList": "Bulleted list", + "todoList": "Todo List", + "callout": "Callout", "cover": { "changeCover": "Change Cover", "colors": "Colors", @@ -624,18 +643,19 @@ "add": "Add", "back": "Back", "saveToGallery": "Save to gallery", - "removeIcon": "Remove Icon", + "removeIcon": "Remove icon", "pasteImageUrl": "Paste image URL", "or": "OR", "pickFromFiles": "Pick from files", "couldNotFetchImage": "Could not fetch image", "imageSavingFailed": "Image Saving Failed", - "addIcon": "Add Icon", + "addIcon": "Add icon", + "changeIcon": "Change icon", "coverRemoveAlert": "It will be removed from cover after it is deleted.", "alertDialogConfirmation": "Are you sure, you want to continue?" }, "mathEquation": { - "addMathEquation": "Add Math Equation", + "addMathEquation": "Add a TeX equation", "editMathEquation": "Edit Math Equation" }, "optionAction": { @@ -672,7 +692,8 @@ "copy": "Copy", "cut": "Cut", "paste": "Paste" - } + }, + "action": "Actions" }, "textBlock": { "placeholder": "Type '/' for commands" @@ -711,7 +732,11 @@ }, "searchForAnImage": "Search for an image", "pleaseInputYourOpenAIKey": "please input your OpenAI key in Settings page", - "pleaseInputYourStabilityAIKey": "please input your Stability AI key in Settings page" + "pleaseInputYourStabilityAIKey": "please input your Stability AI key in Settings page", + "saveImageToGallery": "Save image", + "failedToAddImageToGallery": "Failed to add image to gallery", + "successToAddImageToGallery": "Image added to gallery successfully", + "unableToLoadImage": "Unable to load image" }, "codeBlock": { "language": { @@ -751,11 +776,21 @@ "board": { "column": { "createNewCard": "New", - "renameGroupTooltip": "Press to rename group" + "renameGroupTooltip": "Press to rename group", + "createNewColumn": "Add a new group", + "addToColumnTopTooltip": "Add a new card at the top", + "renameColumn": "Rename", + "hideColumn": "Hide" + }, + "hiddenGroupSection": { + "sectionTitle": "Hidden Groups", + "collapseTooltip": "Hide the hidden groups", + "expandTooltip": "View the hidden groups" }, "menuName": "Board", "showUngrouped": "Show ungrouped items", "ungroupedButtonText": "Ungrouped", + "ungroupedButtonTooltip": "Contains cards that don't belong in any group", "ungroupedItemsTitle": "Click to add to the board", "groupBy": "Group by", "referencedBoardPrefix": "View of" @@ -776,7 +811,11 @@ "firstDayOfWeek": "Start week on", "layoutDateField": "Layout calendar by", "noDateTitle": "No Date", - "noDateHint": "Unscheduled events will show up here", + "noDateHint": { + "zero": "Unscheduled events will show up here", + "one": "{} unscheduled event", + "other": "{} unscheduled events" + }, "clickToAdd": "Click to add to the calendar", "name": "Calendar layout" }, @@ -818,6 +857,7 @@ "gray": "Gray" }, "emoji": { + "emojiTab": "Emoji", "search": "Search emoji", "noRecent": "No recent emoji", "noEmojiFound": "No emoji found", @@ -837,6 +877,14 @@ "flags": "Flags", "nature": "Nature", "frequentlyUsed": "Frequently Used" + }, + "skinTone": { + "default": "Default", + "light": "Light", + "mediumLight": "Medium-Light", + "medium": "Medium", + "mediumDark": "Medium-Dark", + "dark": "Dark" } }, "inlineActions": { @@ -1015,5 +1063,8 @@ "favorite": { "noFavorite": "No favorite page", "noFavoriteHintText": "Swipe the page to the left to add it to your favorites" + }, + "cardDetails": { + "notesPlaceholder": "Enter a / to insert a block, or start typing" } } \ No newline at end of file diff --git a/frontend/resources/translations/es-VE.json b/frontend/resources/translations/es-VE.json index 5620e986d154..33b7e1da8c08 100644 --- a/frontend/resources/translations/es-VE.json +++ b/frontend/resources/translations/es-VE.json @@ -159,9 +159,7 @@ "editContact": "Editar Contacto" }, "button": { - "OK": "OK", - "Done": "Hecho", - "Cancel": "Cancelar", + "done": "Hecho", "signIn": "Ingresar", "signOut": "Salir", "complete": "Completar", @@ -177,8 +175,10 @@ "edit": "Editar", "delete": "Borrar", "duplicate": "Duplicar", - "done": "Hecho", - "putback": "Volver" + "putback": "Volver", + "OK": "OK", + "Done": "Hecho", + "Cancel": "Cancelar" }, "label": { "welcome": "¡Bienvenido!", @@ -547,11 +547,11 @@ } }, "board": { + "menuName": "Junta", + "referencedBoardPrefix": "Vista de", "column": { "create_new_card": "Nuevo" - }, - "menuName": "Junta", - "referencedBoardPrefix": "Vista de" + } }, "calendar": { "menuName": "Calendario", @@ -568,9 +568,9 @@ "firstDayOfWeek": "Empieza la semana en", "layoutDateField": "Diseño de calendario por", "noDateTitle": "Sin cita", - "noDateHint": "Los eventos no programados se mostrarán aquí", "clickToAdd": "Haga clic para agregar al calendario", - "name": "Diseño de calendario" + "name": "Diseño de calendario", + "noDateHint": "Los eventos no programados se mostrarán aquí" }, "referencedCalendarPrefix": "Vista de" }, diff --git a/frontend/resources/translations/eu-ES.json b/frontend/resources/translations/eu-ES.json index 993d30062524..ac96d60104ad 100644 --- a/frontend/resources/translations/eu-ES.json +++ b/frontend/resources/translations/eu-ES.json @@ -159,9 +159,7 @@ "editContact": "Kontaktua editatu" }, "button": { - "OK": "OK", - "Done": "Eginda", - "Cancel": "Ezteztatu", + "done": "Eginda", "signIn": "Saioa hasi", "signOut": "Saioa itxi", "complete": "Burututa", @@ -177,8 +175,10 @@ "edit": "Editatu", "delete": "Ezabatu", "duplicate": "Bikoiztu", - "done": "Eginda", - "putback": "Jarri Atzera" + "putback": "Jarri Atzera", + "OK": "OK", + "Done": "Eginda", + "Cancel": "Ezteztatu" }, "label": { "welcome": "Ongi etorri!", @@ -552,11 +552,11 @@ } }, "board": { + "menuName": "Kontseilua", + "referencedBoardPrefix": "-ren ikuspegia", "column": { "create_new_card": "Berria" - }, - "menuName": "Kontseilua", - "referencedBoardPrefix": "-ren ikuspegia" + } }, "calendar": { "menuName": "Egutegia", @@ -573,9 +573,9 @@ "firstDayOfWeek": "Hasi astea", "layoutDateField": "Egutegiaren diseinua arabera", "noDateTitle": "Datarik ez", - "noDateHint": "Programatu gabeko gertaerak hemen agertuko dira", "clickToAdd": "Egin klik egutegian gehitzeko", - "name": "Egutegiaren diseinua" + "name": "Egutegiaren diseinua", + "noDateHint": "Programatu gabeko gertaerak hemen agertuko dira" }, "referencedCalendarPrefix": "-ren ikuspegia" }, diff --git a/frontend/resources/translations/fa.json b/frontend/resources/translations/fa.json index 6eebfc47689c..77e71f5e80f0 100644 --- a/frontend/resources/translations/fa.json +++ b/frontend/resources/translations/fa.json @@ -175,9 +175,7 @@ "editContact": "ویرایش مخاطب" }, "button": { - "OK": "باشه", - "Done": "انجام شد", - "Cancel": "لغو", + "done": "انجام شد", "signIn": "ورود", "signOut": "خروج", "complete": "کامل شد", @@ -193,8 +191,10 @@ "edit": "ویرایش", "delete": "حذف کردن", "duplicate": "تکرار کردن", - "done": "انجام شد", - "putback": "بازگشت" + "putback": "بازگشت", + "OK": "باشه", + "Done": "انجام شد", + "Cancel": "لغو" }, "label": { "welcome": "خوش آمدید!", @@ -593,11 +593,11 @@ } }, "board": { + "menuName": "بورد", + "referencedBoardPrefix": "نمای", "column": { "create_new_card": "ایجاد" - }, - "menuName": "بورد", - "referencedBoardPrefix": "نمای" + } }, "calendar": { "menuName": "تقویم", @@ -614,9 +614,9 @@ "firstDayOfWeek": "شروع هفته در", "layoutDateField": "طرح‌بندی تقویم با", "noDateTitle": "بدون تاریخ", - "noDateHint": "رویدادهای برنامه‌ریزی نشده در اینجا نشان داده می‌شوند", "clickToAdd": "برای افزودن به تقویم کلیک کنید", - "name": "طرح‌بندی تقویم" + "name": "طرح‌بندی تقویم", + "noDateHint": "رویدادهای برنامه‌ریزی نشده در اینجا نشان داده می‌شوند" }, "referencedCalendarPrefix": "نمای" }, diff --git a/frontend/resources/translations/fr-CA.json b/frontend/resources/translations/fr-CA.json index 5b9af705e108..08c1af4a87a4 100644 --- a/frontend/resources/translations/fr-CA.json +++ b/frontend/resources/translations/fr-CA.json @@ -159,9 +159,7 @@ "editContact": "Modifier le contact" }, "button": { - "OK": "OK", - "Done": "Fait", - "Cancel": "Annuler", + "done": "Fait", "signIn": "Se connecter", "signOut": "Se déconnecter", "complete": "Achevé", @@ -177,8 +175,10 @@ "edit": "Modifier", "delete": "Supprimer", "duplicate": "Dupliquer", - "done": "Fait", - "putback": "Remettre" + "putback": "Remettre", + "OK": "OK", + "Done": "Fait", + "Cancel": "Annuler" }, "label": { "welcome": "Bienvenue!", @@ -546,11 +546,11 @@ } }, "board": { + "menuName": "Conseil", + "referencedBoardPrefix": "Vue", "column": { "create_new_card": "Nouveau" - }, - "menuName": "Conseil", - "referencedBoardPrefix": "Vue" + } }, "calendar": { "menuName": "Calendrier", @@ -567,9 +567,9 @@ "firstDayOfWeek": "Commencer la semaine le", "layoutDateField": "Calendrier de mise en page par", "noDateTitle": "Pas de date", - "noDateHint": "Les événements non planifiés s'afficheront ici", "clickToAdd": "Cliquez pour ajouter au calendrier", - "name": "Disposition du calendrier" + "name": "Disposition du calendrier", + "noDateHint": "Les événements non planifiés s'afficheront ici" }, "referencedCalendarPrefix": "Vue" }, diff --git a/frontend/resources/translations/fr-FR.json b/frontend/resources/translations/fr-FR.json index cf77a54bd03e..298ec4393ef7 100644 --- a/frontend/resources/translations/fr-FR.json +++ b/frontend/resources/translations/fr-FR.json @@ -184,9 +184,7 @@ "editContact": "Modifier le contact" }, "button": { - "OK": "OK", - "Done": "Fait", - "Cancel": "Annuler", + "done": "Fait", "signIn": "Se connecter", "signOut": "Se déconnecter", "complete": "Achevé", @@ -202,12 +200,14 @@ "edit": "Modifier", "delete": "Supprimer", "duplicate": "Dupliquer", - "done": "Fait", "putback": "Remettre", "share": "Partager", "removeFromFavorites": "Retirer des favoris", "addToFavorites": "Ajouter aux favoris", - "rename": "Renommer" + "rename": "Renommer", + "OK": "OK", + "Done": "Fait", + "Cancel": "Annuler" }, "label": { "welcome": "Bienvenue !", @@ -630,11 +630,11 @@ } }, "board": { + "menuName": "Conseil", + "referencedBoardPrefix": "Vue", "column": { "create_new_card": "Nouveau" - }, - "menuName": "Conseil", - "referencedBoardPrefix": "Vue" + } }, "calendar": { "menuName": "Calendrier", @@ -651,9 +651,9 @@ "firstDayOfWeek": "Commencer la semaine le", "layoutDateField": "Calendrier de mise en page par", "noDateTitle": "Pas de date", - "noDateHint": "Les événements non planifiés s'afficheront ici", "clickToAdd": "Cliquez pour ajouter au calendrier", - "name": "Disposition du calendrier" + "name": "Disposition du calendrier", + "noDateHint": "Les événements non planifiés s'afficheront ici" }, "referencedCalendarPrefix": "Vue" }, diff --git a/frontend/resources/translations/hu-HU.json b/frontend/resources/translations/hu-HU.json index 6c897a580435..8f24a8e102a9 100644 --- a/frontend/resources/translations/hu-HU.json +++ b/frontend/resources/translations/hu-HU.json @@ -159,9 +159,7 @@ "editContact": "Kontakt Szerkesztése" }, "button": { - "OK": "OK", - "Done": "Kész", - "Cancel": "Mégse", + "done": "Kész", "signIn": "Bejelentkezés", "signOut": "Kijelentkezés", "complete": "Kész", @@ -177,8 +175,10 @@ "edit": "Szerkesztés", "delete": "Töröl", "duplicate": "Másolat", - "done": "Kész", - "putback": "Visszatesz" + "putback": "Visszatesz", + "OK": "OK", + "Done": "Kész", + "Cancel": "Mégse" }, "label": { "welcome": "Üdvözlünk!", @@ -546,11 +546,11 @@ } }, "board": { + "menuName": "Tábla", + "referencedBoardPrefix": "Nézet", "column": { "create_new_card": "Új" - }, - "menuName": "Tábla", - "referencedBoardPrefix": "Nézet" + } }, "calendar": { "menuName": "Naptár", @@ -567,9 +567,9 @@ "firstDayOfWeek": "Kezdje a hetet", "layoutDateField": "Elrendezés naptár által", "noDateTitle": "Nincs dátum", - "noDateHint": "A nem tervezett események itt jelennek meg", "clickToAdd": "Kattintson a naptárhoz való hozzáadáshoz", - "name": "Naptár elrendezés" + "name": "Naptár elrendezés", + "noDateHint": "A nem tervezett események itt jelennek meg" }, "referencedCalendarPrefix": "Nézet" }, diff --git a/frontend/resources/translations/id-ID.json b/frontend/resources/translations/id-ID.json index e47f2e7c753b..7c80d99407c9 100644 --- a/frontend/resources/translations/id-ID.json +++ b/frontend/resources/translations/id-ID.json @@ -13,6 +13,7 @@ "addAboveCmd": "Alt+klik", "addAboveMacCmd": "Opsi+klik", "addAboveTooltip": "untuk menambahkan di atas", + "dragTooltip": "Seret untuk pindahkan", "openMenuTooltip": "Klik untuk membuka menu" }, "signUp": { @@ -116,6 +117,11 @@ "confirmRestoreAll": { "title": "Apakah Anda yakin akan memulihkan semua laman di Sampah?", "caption": "Tindakan ini tidak bisa dibatalkan." + }, + "mobile": { + "actions": "Tindakan Sampah", + "empty": "Tempat Sampah Kosong", + "emptyDescription": "Anda tidak memiliki file yang dihapus" } }, "deletePagePrompt": { @@ -142,6 +148,7 @@ "defaultNewPageName": "Tanpa Judul", "renameDialog": "Ganti nama" }, + "noPagesInside": "Tidak ada halaman di dalamnya", "toolbar": { "undo": "Undo", "redo": "Redo", @@ -193,9 +200,9 @@ "editContact": "Ubah Kontak" }, "button": { - "OK": "Ya", - "Done": "Selesai", - "Cancel": "Batal", + "ok": "OKE", + "done": "Selesai", + "cancel": "Batal", "signIn": "Masuk", "signOut": "Keluar", "complete": "Selesai", @@ -211,8 +218,16 @@ "edit": "Sunting", "delete": "Menghapus", "duplicate": "Duplikat", - "done": "Selesai", - "putback": "Taruh kembali" + "putback": "Taruh kembali", + "update": "Perbarui", + "share": "Bagikan", + "removeFromFavorites": "Hapus dari favorit", + "addToFavorites": "Tambahkan ke Favorit", + "rename": "Ganti nama", + "helpCenter": "Pusat Bantuan", + "OK": "Ya", + "Done": "Selesai", + "Cancel": "Batal" }, "label": { "welcome": "Selamat datang!", @@ -374,6 +389,17 @@ "resetToDefault": "Mengatur ulang ke keybinding default", "couldNotLoadErrorMsg": "Tidak dapat memuat pintasan, Coba lagi", "couldNotSaveErrorMsg": "Tidak dapat menyimpan pintasan, Coba lagi" + }, + "mobile": { + "personalInfo": "Informasi pribadi", + "username": "Nama Pengguna", + "usernameEmptyError": "Nama pengguna tidak boleh kosong", + "about": "Tentang", + "pushNotifications": "Pemberitahuan Dorong", + "support": "Dukungan", + "joinDiscord": "Bergabunglah dengan kami di Discord", + "privacyPolicy": "Kebijakan Privasi", + "userAgreement": "Perjanjian Pengguna" } }, "grid": { @@ -531,7 +557,9 @@ "checklist": { "taskHint": "Deskripsi tugas", "addNew": "Tambahkan item", - "submitNewTask": "Buat" + "submitNewTask": "Buat", + "hideComplete": "Sembunyikan tugas yang sudah selesai", + "showComplete": "Tampilkan semua tugas" }, "menuName": "Grid", "referencedGridPrefix": "Pemandangan dari" @@ -724,9 +752,16 @@ }, "board": { "column": { + "createNewCard": "Baru", + "renameGroupTooltip": "Tekan untuk mengganti nama grup", "create_new_card": "Baru" }, "menuName": "Papan", + "showUngrouped": "Tampilkan item yang tidak dikelompokkan", + "ungroupedButtonText": "Tidak dikelompokkan", + "ungroupedButtonTooltip": "Berisi kartu yang tidak termasuk dalam grup mana pun", + "ungroupedItemsTitle": "Klik untuk menambahkan ke papan", + "groupBy": "Kelompokkan berdasarkan", "referencedBoardPrefix": "Pemandangan dari" }, "calendar": { @@ -817,6 +852,9 @@ "shortKeyword": "mengingatkan" } }, + "datePicker": { + "dateTimeFormatTooltip": "Ubah format tanggal dan waktu di pengaturan" + }, "relativeDates": { "yesterday": "Kemarin", "today": "Hari ini", @@ -825,10 +863,17 @@ }, "notificationHub": { "title": "Notifikasi", + "emptyTitle": "Semua sudah ketahuan!", + "emptyBody": "Tidak ada pemberitahuan atau tindakan yang tertunda. Nikmati ketenangannya.", "tabs": { "inbox": "Kotak masuk", "upcoming": "Mendatang" }, + "actions": { + "markAllRead": "tandai semua telah dibaca", + "showAll": "Semua", + "showUnreads": "Belum dibaca" + }, "filters": { "ascending": "Ascending", "descending": "Descending", @@ -854,5 +899,126 @@ "replaceAll": "Timpa semua", "noResult": "Tidak ada hasil", "caseSensitive": "Case sensitive" + }, + "error": { + "weAreSorry": "Kami meminta maaf", + "loadingViewError": "Kami mengalami masalah saat memuat tampilan ini. Silakan periksa koneksi internet Anda, segarkan aplikasi, dan jangan ragu untuk menghubungi tim jika masalah terus berlanjut." + }, + "editor": { + "bold": "Tebal", + "bulletedList": "Daftar Berpoin", + "checkbox": "Kotak centang", + "embedCode": "Sematkan Kode", + "heading1": "H1", + "heading2": "H2", + "heading3": "H3", + "highlight": "Sorotan", + "color": "Warna", + "image": "Gambar", + "italic": "Miring", + "link": "Tautan", + "numberedList": "Daftar Bernomor", + "quote": "Kutipan", + "strikethrough": "Dicoret", + "text": "Teks", + "underline": "Garis Bawah", + "fontColorDefault": "Bawaan", + "fontColorGray": "Abu-abu", + "fontColorBrown": "Cokelat", + "fontColorOrange": "Oranye", + "fontColorYellow": "Kuning", + "fontColorGreen": "Hijau", + "fontColorBlue": "Biru", + "fontColorPurple": "Ungu", + "fontColorPink": "Merah Jambu", + "fontColorRed": "Merah", + "backgroundColorDefault": "Latar belakang bawaan", + "backgroundColorGray": "Latar belakang abu-abu", + "backgroundColorBrown": "Latar belakang coklat", + "backgroundColorOrange": "Latar belakang oranye", + "backgroundColorYellow": "Latar belakang kuning", + "backgroundColorGreen": "Latar belakang hijau", + "backgroundColorBlue": "Latar belakang biru", + "backgroundColorPurple": "Latar belakang ungu", + "backgroundColorPink": "Latar belakang merah muda", + "backgroundColorRed": "Latar belakang merah", + "done": "Selesai", + "cancel": "Batalkan", + "tint1": "Warna 1", + "tint2": "Warna 2", + "tint3": "Warna 3", + "tint4": "Warna 4", + "tint5": "Warna 5", + "tint6": "Warna 6", + "tint7": "Warna 7", + "tint8": "Warna 8", + "tint9": "Warna 9", + "lightLightTint1": "Ungu", + "lightLightTint2": "Merah Jambu", + "lightLightTint3": "Merah Jambu Muda", + "lightLightTint4": "Oranye", + "lightLightTint5": "Kuning", + "lightLightTint6": "Hijau Jeruk Nipis", + "lightLightTint7": "Hijau", + "lightLightTint8": "Biru Air", + "lightLightTint9": "Biru", + "urlHint": "URL", + "mobileHeading1": "Judul 1", + "mobileHeading2": "Judul 2", + "mobileHeading3": "Judul 3", + "textColor": "Warna Teks", + "backgroundColor": "Warna Latar Belakang", + "addYourLink": "Tambahkan tautan Anda", + "openLink": "Buka tautan", + "copyLink": "Salin tautan", + "removeLink": "Hapus tautan", + "editLink": "Sunting tautan", + "linkText": "Teks", + "linkTextHint": "Silakan masukkan teks", + "linkAddressHint": "Silakan masukkan URL", + "highlightColor": "Sorot warna", + "clearHighlightColor": "Hapus warna sorotan", + "customColor": "Warna khusus", + "hexValue": "Nilai hex", + "opacity": "Kegelapan", + "resetToDefaultColor": "Atur ulang ke warna default", + "ltr": "LTR", + "rtl": "RTL", + "auto": "Otomatis", + "cut": "Potong", + "copy": "Salin", + "paste": "Tempel", + "find": "Temukan", + "previousMatch": "Padanan sebelumnya", + "nextMatch": "Padanan selanjutnya", + "closeFind": "Tutup", + "replace": "Ganti", + "replaceAll": "Ganti semua", + "regex": "Regex", + "caseSensitive": "Huruf besar kecil dibedakan", + "uploadImage": "Unggah Gambar", + "urlImage": "Gambar URL", + "incorrectLink": "Tautan Salah", + "upload": "Unggah", + "chooseImage": "Pilih gambar", + "loading": "Memuat", + "imageLoadFailed": "Tidak dapat memuat gambar", + "divider": "Pembagi", + "table": "Tabel", + "colAddBefore": "Tambahkan sebelumnya", + "rowAddBefore": "Tambahkan sebelumnya", + "colAddAfter": "Tambahkan setelahnya", + "rowAddAfter": "Tambahkan setelahnya", + "colRemove": "Hapus", + "rowRemove": "Hapus", + "colDuplicate": "Duplikat", + "rowDuplicate": "Duplikat", + "colClear": "Hapus Konten", + "rowClear": "Hapus Konten", + "slashPlaceHolder": "Masukkan / untuk menyisipkan blok, atau mulai mengetik" + }, + "favorite": { + "noFavorite": "Tidak ada halaman favorit", + "noFavoriteHintText": "Geser halaman ke kiri untuk menambahkannya ke favorit Anda" } } \ No newline at end of file diff --git a/frontend/resources/translations/it-IT.json b/frontend/resources/translations/it-IT.json index e7214164c835..d797240b17f4 100644 --- a/frontend/resources/translations/it-IT.json +++ b/frontend/resources/translations/it-IT.json @@ -160,9 +160,7 @@ "editContact": "Modifica Contatti" }, "button": { - "OK": "OK", - "Done": "Fatto", - "Cancel": "Annulla", + "done": "Fatto", "signIn": "Accedi", "signOut": "Esci", "complete": "Completa", @@ -178,8 +176,10 @@ "edit": "Modificare", "delete": "Eliminare", "duplicate": "Duplicare", - "done": "Fatto", - "putback": "Rimettere a posto" + "putback": "Rimettere a posto", + "OK": "OK", + "Done": "Fatto", + "Cancel": "Annulla" }, "label": { "welcome": "Benvenuto!", @@ -558,11 +558,11 @@ } }, "board": { + "menuName": "Asse", + "referencedBoardPrefix": "Vista di", "column": { "create_new_card": "Nuovo" - }, - "menuName": "Asse", - "referencedBoardPrefix": "Vista di" + } }, "calendar": { "menuName": "Calendario", @@ -579,9 +579,9 @@ "firstDayOfWeek": "Inizia la settimana", "layoutDateField": "Layout calendario per", "noDateTitle": "Nessuna data", - "noDateHint": "Gli eventi non programmati verranno visualizzati qui", "clickToAdd": "Fare clic per aggiungere al calendario", - "name": "Disposizione del calendario" + "name": "Disposizione del calendario", + "noDateHint": "Gli eventi non programmati verranno visualizzati qui" }, "referencedCalendarPrefix": "Vista di" }, diff --git a/frontend/resources/translations/ja-JP.json b/frontend/resources/translations/ja-JP.json index baba757890fb..9237dad5e33f 100644 --- a/frontend/resources/translations/ja-JP.json +++ b/frontend/resources/translations/ja-JP.json @@ -159,9 +159,7 @@ "editContact": "連絡先を編集する" }, "button": { - "OK": "OK", - "Done": "終わり", - "Cancel": "キャンセル", + "done": "終わり", "signIn": "サインイン", "signOut": "サインアウト", "complete": "完了", @@ -177,8 +175,10 @@ "edit": "編集", "delete": "消去", "duplicate": "複製", - "done": "終わり", - "putback": "戻す" + "putback": "戻す", + "OK": "OK", + "Done": "終わり", + "Cancel": "キャンセル" }, "label": { "welcome": "ようこそ!", @@ -547,11 +547,11 @@ } }, "board": { + "menuName": "ボード", + "referencedBoardPrefix": "のビュー", "column": { "create_new_card": "新しい" - }, - "menuName": "ボード", - "referencedBoardPrefix": "のビュー" + } }, "calendar": { "menuName": "カレンダー", @@ -568,9 +568,9 @@ "firstDayOfWeek": "週の開始日", "layoutDateField": "レイアウトカレンダー", "noDateTitle": "日付なし", - "noDateHint": "予定外のイベントがここに表示されます", "clickToAdd": "クリックしてカレンダーに追加します", - "name": "カレンダーのレイアウト" + "name": "カレンダーのレイアウト", + "noDateHint": "予定外のイベントがここに表示されます" }, "referencedCalendarPrefix": "のビュー" }, diff --git a/frontend/resources/translations/ko-KR.json b/frontend/resources/translations/ko-KR.json index 0d62c5de4020..98bd96640be4 100644 --- a/frontend/resources/translations/ko-KR.json +++ b/frontend/resources/translations/ko-KR.json @@ -159,9 +159,7 @@ "editContact": "연락처 편집" }, "button": { - "OK": "확인", - "Done": "완료", - "Cancel": "취소", + "done": "완료", "signIn": "로그인", "signOut": "로그아웃", "complete": "완료", @@ -177,8 +175,10 @@ "edit": "편집하다", "delete": "삭제", "duplicate": "복제하다", - "done": "완료", - "putback": "다시 집어 넣어" + "putback": "다시 집어 넣어", + "OK": "확인", + "Done": "완료", + "Cancel": "취소" }, "label": { "welcome": "환영합니다!", @@ -549,11 +549,11 @@ } }, "board": { + "menuName": "판자", + "referencedBoardPrefix": "관점", "column": { "create_new_card": "추가" - }, - "menuName": "판자", - "referencedBoardPrefix": "관점" + } }, "calendar": { "menuName": "달력", @@ -570,9 +570,9 @@ "firstDayOfWeek": "주 시작", "layoutDateField": "레이아웃 캘린더", "noDateTitle": "날짜 없음", - "noDateHint": "예약되지 않은 일정이 여기에 표시됩니다.", "clickToAdd": "캘린더에 추가하려면 클릭하세요.", - "name": "달력 레이아웃" + "name": "달력 레이아웃", + "noDateHint": "예약되지 않은 일정이 여기에 표시됩니다." }, "referencedCalendarPrefix": "관점" }, diff --git a/frontend/resources/translations/pl-PL.json b/frontend/resources/translations/pl-PL.json index 426c1287acbb..c0489cd08d4b 100644 --- a/frontend/resources/translations/pl-PL.json +++ b/frontend/resources/translations/pl-PL.json @@ -189,9 +189,7 @@ "editContact": "Edytuj Kontakt" }, "button": { - "OK": "OK", - "Done": "Zrobione", - "Cancel": "Anuluj", + "done": "Zrobione", "signIn": "Zaloguj", "signOut": "Wyloguj", "complete": "Zakończono", @@ -207,8 +205,10 @@ "edit": "Edytuj", "delete": "Usuń", "duplicate": "Duplikuj", - "done": "Zrobione", - "putback": "Odłóż z powrotem" + "putback": "Odłóż z powrotem", + "OK": "OK", + "Done": "Zrobione", + "Cancel": "Anuluj" }, "label": { "welcome": "Witaj!", @@ -656,11 +656,11 @@ } }, "board": { + "menuName": "Tablica", + "referencedBoardPrefix": "Widok", "column": { "create_new_card": "Nowy" - }, - "menuName": "Tablica", - "referencedBoardPrefix": "Widok" + } }, "calendar": { "menuName": "Kalendarz", @@ -678,9 +678,9 @@ "firstDayOfWeek": "Rozpocznij tydzień", "layoutDateField": "Układ kalendarza wg", "noDateTitle": "Brak daty", - "noDateHint": "Tutaj pojawią się nieplanowane wydarzenia", "clickToAdd": "Kliknij, aby dodać do kalendarza", - "name": "Układ kalendarza" + "name": "Układ kalendarza", + "noDateHint": "Tutaj pojawią się nieplanowane wydarzenia" }, "referencedCalendarPrefix": "Widok" }, diff --git a/frontend/resources/translations/pt-BR.json b/frontend/resources/translations/pt-BR.json index facd2ea5b5f9..9ec246a85e41 100644 --- a/frontend/resources/translations/pt-BR.json +++ b/frontend/resources/translations/pt-BR.json @@ -183,9 +183,7 @@ "editContact": "Editar um contato" }, "button": { - "OK": "Ok", - "Done": "Feito", - "Cancel": "Cancelar", + "done": "Feito", "signIn": "Conectar", "signOut": "Desconectar", "complete": "Completar", @@ -201,8 +199,10 @@ "edit": "Editar", "delete": "Excluir", "duplicate": "Duplicado", - "done": "Feito", "putback": "Por de volta", + "OK": "Ok", + "Done": "Feito", + "Cancel": "Cancelar", "tryAGain": "Tentar novamente" }, "label": { @@ -630,11 +630,11 @@ } }, "board": { + "menuName": "Quadro", + "referencedBoardPrefix": "Vista de", "column": { "create_new_card": "Novo" - }, - "menuName": "Quadro", - "referencedBoardPrefix": "Vista de" + } }, "calendar": { "menuName": "Calendário", @@ -651,9 +651,9 @@ "firstDayOfWeek": "Comece a semana em", "layoutDateField": "Calendário de layout por", "noDateTitle": "sem data", - "noDateHint": "Eventos não agendados aparecerão aqui", "clickToAdd": "Clique para adicionar ao calendário", - "name": "Layout do calendário" + "name": "Layout do calendário", + "noDateHint": "Eventos não agendados aparecerão aqui" }, "referencedCalendarPrefix": "Vista de" }, diff --git a/frontend/resources/translations/pt-PT.json b/frontend/resources/translations/pt-PT.json index 805109c9355f..7556474d2b6e 100644 --- a/frontend/resources/translations/pt-PT.json +++ b/frontend/resources/translations/pt-PT.json @@ -194,9 +194,7 @@ "editContact": "Editar um conctato" }, "button": { - "OK": "OK", - "Done": "Feito", - "Cancel": "Cancelar", + "done": "Feito", "signIn": "Entrar", "signOut": "Sair", "complete": "Completar", @@ -212,8 +210,10 @@ "edit": "Editar", "delete": "Excluir", "duplicate": "Duplicado", - "done": "Feito", - "putback": "Por de volta" + "putback": "Por de volta", + "OK": "OK", + "Done": "Feito", + "Cancel": "Cancelar" }, "label": { "welcome": "Bem vindo!", @@ -725,11 +725,11 @@ } }, "board": { + "menuName": "Quadro", + "referencedBoardPrefix": "Vista de", "column": { "create_new_card": "Novo" - }, - "menuName": "Quadro", - "referencedBoardPrefix": "Vista de" + } }, "calendar": { "menuName": "Calendário", @@ -747,9 +747,9 @@ "firstDayOfWeek": "Comece a semana em", "layoutDateField": "Calendário de layout por", "noDateTitle": "sem data", - "noDateHint": "Eventos não agendados aparecerão aqui", "clickToAdd": "Clique para adicionar ao calendário", - "name": "Layout do calendário" + "name": "Layout do calendário", + "noDateHint": "Eventos não agendados aparecerão aqui" }, "referencedCalendarPrefix": "Vista de" }, diff --git a/frontend/resources/translations/ru-RU.json b/frontend/resources/translations/ru-RU.json index 7afb27d4c20f..2f008110ea37 100644 --- a/frontend/resources/translations/ru-RU.json +++ b/frontend/resources/translations/ru-RU.json @@ -10,9 +10,11 @@ "and": "и", "blockActions": { "addBelowTooltip": "Нажмите, чтобы добавить ниже", - "addAboveCmd": "Alt+щелчок", - "addAboveMacCmd": "Option+щелчок", - "addAboveTooltip": "добавить выше" + "addAboveCmd": "Alt+клик", + "addAboveMacCmd": "Option+клик", + "addAboveTooltip": "добавить выше", + "dragTooltip": "Перетащите для перемещения", + "openMenuTooltip": "Нажмите, чтобы открыть меню" }, "signUp": { "buttonText": "Зарегистрироваться", @@ -24,11 +26,14 @@ "alreadyHaveAnAccount": "Уже есть аккаунт?", "emailHint": "Электронная почта", "passwordHint": "Пароль", - "repeatPasswordHint": "Повторите пароль" + "repeatPasswordHint": "Повторите пароль", + "signUpWith": "Зарегистрируйтесь с помощью:" }, "signIn": { "loginTitle": "Войти в @:appName", "loginButtonText": "Войти", + "loginStartWithAnonymous": "Начать анонимный сеанс", + "continueAnonymousUser": "Продолжить анонимный сеанс", "buttonText": "Авторизация", "forgotPassword": "Забыли пароль?", "emailHint": "Электронная почта", @@ -36,17 +41,32 @@ "dontHaveAnAccount": "Нет аккаунта?", "repeatPasswordEmptyError": "Повтор пароля не может быть пустым", "unmatchedPasswordError": "Пароли не совпадают", + "syncPromptMessage": "Синхронизация данных может занять некоторое время. Пожалуйста, не закрывайте эту страницу", + "or": "или", + "LogInWithGoogle": "Войти через Google", + "LogInWithGithub": "Войти через Github", + "LogInWithDiscord": "Войти через Discord", + "signInWith": "Войти с помощью:", "loginAsGuestButtonText": "Начать" }, "workspace": { + "chooseWorkspace": "Выберите рабочее пространство", "create": "Создать рабочее пространство", + "reset": "Сбросить рабочее пространство", + "resetWorkspacePrompt": "Сброс рабочей области приведет к удалению всех страниц и данных в ней. Вы уверены, что хотите сбросить рабочую область? Также, вы можете обратиться в службу поддержки для восстановления рабочей области.", "hint": "рабочее пространство", - "notFoundError": "Нет такого рабочего пространства" + "notFoundError": "Рабочее пространство не найдено", + "failedToLoad": "Что-то пошло не так! Не удалось загрузить рабочее пространство. Попробуйте закрыть открытый сеанс AppFlowy и повторите попытку.", + "errorActions": { + "reportIssue": "Сообщить о проблеме", + "reachOut": "Обратитесь в Discord" + } }, "shareAction": { "buttonText": "Поделиться", "workInProgress": "В разработке", "markdown": "Markdown", + "csv": "CSV", "copyLink": "Скопировать ссылку" }, "moreAction": { @@ -54,7 +74,8 @@ "medium": "средний", "large": "большой", "fontSize": "Размер шрифта", - "import": "Импортировать" + "import": "Импортировать", + "moreOptions": "Больше вариантов" }, "importPanel": { "textAndMarkdown": "Текст и Markdown", @@ -67,16 +88,25 @@ "rename": "Переименовать", "delete": "Удалить", "duplicate": "Дублировать", - "openNewTab": "Открыть в новой вкладке" + "unfavorite": "Удалить из избранного", + "favorite": "Добавить в избранное", + "openNewTab": "Открыть в новой вкладке", + "moveTo": "Переместить в", + "addToFavorites": "Добавить в избранное", + "copyLink": "Скопировать ссылку" }, "blankPageTitle": "Пустая страница", "newPageText": "Новая страница", + "newDocumentText": "Новый документ", + "newGridText": "Новая сетка", + "newCalendarText": "Новый календарь", + "newBoardText": "Новая доска", "trash": { "text": "Корзина", "restoreAll": "Восстановить всё", "deleteAll": "Очистить", "pageHeader": { - "fileName": "Имя", + "fileName": "Имя файла", "lastModified": "Последнее изменение", "created": "Создан" }, @@ -87,6 +117,11 @@ "confirmRestoreAll": { "title": "Вы уверены, что хотите восстановить все страницы в Корзине?", "caption": "Это действие не может быть отменено." + }, + "mobile": { + "actions": "Действия с корзиной", + "empty": "Корзина пуста", + "emptyDescription": "У вас нет удаленных файлов" } }, "deletePagePrompt": { @@ -103,15 +138,17 @@ "debug": { "name": "Отладочная информация", "success": "Скопировано в буфер обмена!", - "fail": "Не получилось скопировать" + "fail": "Не получилось скопировать отладочную информацию в буфер обмена" }, "feedback": "Обратная связь" }, "menuAppHeader": { + "moreButtonToolTip": "Удалить, переименовать и многое другое...", "addPageTooltip": "Быстро добавить новую страницу", "defaultNewPageName": "Без заголовка", "renameDialog": "Переименовать" }, + "noPagesInside": "Внутри нет страниц", "toolbar": { "undo": "Отменить", "redo": "Повторить", @@ -133,9 +170,9 @@ "tooltip": { "lightMode": "Переключить на светлую тему", "darkMode": "Переключить на тёмную тему", - "openAsPage": "Открыть как страницу", + "openAsPage": "Открыть как Страницу", "addNewRow": "Добавить новую строку", - "openMenu": "Открыть меню", + "openMenu": "Нажмите, чтобы открыть меню", "dragRow": "Долгое нажатие для изменения порядка строк", "viewDataBase": "Открыть базу данных", "referencePage": "Ссылки на {name}", @@ -143,7 +180,12 @@ }, "sideBar": { "closeSidebar": "Закрыть боковое меню", - "openSidebar": "Открыть боковое меню" + "openSidebar": "Открыть боковое меню", + "personal": "Личное", + "favorites": "Избранное", + "clickToHidePersonal": "Нажмите, чтобы скрыть личный раздел", + "clickToHideFavorites": "Нажмите, чтобы скрыть раздел избранного", + "addAPage": "Добавить страницу" }, "notifications": { "export": { @@ -158,15 +200,15 @@ "editContact": "Редактировать контакт" }, "button": { - "OK": "OK", - "Done": "Завершить", - "Cancel": "Отмена", + "ok": "ОК", + "done": "Готово", + "cancel": "Отмена", "signIn": "Войти", "signOut": "Выйти", "complete": "Завершить", "save": "Сохранить", "generate": "Сгенерировать", - "esc": "ESC", + "esc": "Esc", "keep": "Оставить", "tryAgain": "Повторить", "discard": "Отменить", @@ -176,27 +218,33 @@ "edit": "Редактировать", "delete": "Удалить", "duplicate": "Дублировать", - "done": "Сделанный", - "putback": "Вернуть" + "putback": "Вернуть", + "update": "Обновить", + "share": "Поделиться", + "removeFromFavorites": "Удалить из избранного", + "addToFavorites": "Добавить в избранное", + "rename": "Переименовать", + "helpCenter": "Центр помощи", + "tryAGain": "Повторить" }, "label": { "welcome": "Добро пожаловать!", "firstName": "Имя", "middleName": "Отчество", "lastName": "Фамилия", - "stepX": "Этап {X}" + "stepX": "Шаг {X}" }, "oAuth": { "err": { - "failedTitle": "Не удалось подключиться к вашей учетной записи.", + "failedTitle": "Не удалось подключиться к вашему аккаунту.", "failedMsg": "Убедитесь, что вы завершили вход в своём браузере." }, "google": { "title": "Вход через Google", - "instruction1": "Чтобы импортировать ваши Google Контакты, вам нужно будет авторизовать приложение через браузер.", - "instruction2": "Скопируйте этот код в буфер обмена (нажав кнопку или выделив текст):", - "instruction3": "Пройдите по ссылке и введите этот код:", - "instruction4": "Нажмите на кнопку ниже, когда завершите вход:" + "instruction1": "Чтобы импортировать ваши Google Контакты, вам нужно будет авторизоваться через браузер.", + "instruction2": "Скопируйте этот код в буфер обмена (нажав на иконку или выделив текст):", + "instruction3": "Перейдите по ссылке и введите приведенный выше код:", + "instruction4": "Нажмите кнопку ниже после завершения регистрации:" } }, "settings": { @@ -206,10 +254,31 @@ "language": "Язык", "user": "Пользователь", "files": "Файлы", + "notifications": "Уведомления", "open": "Открыть настройки", + "logout": "Выйти", + "logoutPrompt": "Вы уверены, что хотите выйти?", + "selfEncryptionLogoutPrompt": "Вы действительно хотите выйти? Убедитесь, что вы скопировали секрет шифрования", + "syncSetting": "Настройка синхронизации", + "enableSync": "Включить синхронизацию", + "enableEncrypt": "Шифрование данных", + "enableEncryptPrompt": "Активируйте шифрование для защиты ваших данных с этим секретом. Храните его безопасно; после включения, он не может быть отключён. В случае потери секрета ваши данные будут также потеряны. Нажмите, чтобы скопировать", + "inputEncryptPrompt": "Пожалуйста, введите ваш секрет шифрования для", + "clickToCopySecret": "Нажмите, чтобы скопировать секрет", + "inputTextFieldHint": "Ваш секрет", + "historicalUserList": "История входа пользователя", + "historicalUserListTooltip": "В этом списке отображаются ваши анонимные аккаунты. Вы можете нажать на аккаунт, чтобы посмотреть данные. Анонимный аккаунт создаётся нажатием кнопки «Начать».", + "openHistoricalUser": "Нажмите, чтобы открыть анонимный аккаунт", "supabaseSetting": "Настройка надбазы" }, + "notifications": { + "enableNotifications": { + "label": "Включить уведомления", + "hint": "Выключите, чтобы локальные уведомления не появлялись." + } + }, "appearance": { + "resetSetting": "Сбросить", "fontFamily": { "label": "Семейство шрифтов", "search": "Поиск" @@ -220,8 +289,23 @@ "dark": "Тёмная", "system": "Системная" }, + "layoutDirection": { + "label": "Направление макета", + "hint": "Управляйте потоком контента на экране слева направо или справа налево.", + "ltr": "Слева направо", + "rtl": "Справа налево" + }, + "textDirection": { + "label": "Направление текста по умолчанию", + "hint": "Укажите направление текста по умолчанию слева или справа.", + "ltr": "Слева направо", + "rtl": "Справа налево", + "auto": "Авто", + "fallback": "То же, что и направление макета" + }, "themeUpload": { "button": "Загрузить", + "uploadTheme": "Загрузить тему", "description": "Загрузите собственную тему AppFlowy, используя кнопку ниже.", "failure": "Загруженная тема имеет недопустимый формат.", "loading": "Подождите, пока мы проверим и загрузим вашу тему...", @@ -232,7 +316,21 @@ }, "theme": "Тема", "builtInsLabel": "Встроенные темы", - "pluginsLabel": "Плагины" + "pluginsLabel": "Плагины", + "dateFormat": { + "label": "Формат даты", + "local": "Локальный", + "us": "ММ-ДД-ГГГГ", + "iso": "ГГГГ-ММ-ДД", + "friendly": "Дружелюбный", + "dmy": "Д/М/Г" + }, + "timeFormat": { + "label": "Формат времени", + "twelveHour": "Двенадцать часов", + "twentyFourHour": "Двадцать четыре часа" + }, + "showNamingDialogWhenCreatingPage": "Показывать диалоговое окно именования при создании страницы" }, "files": { "copy": "Копировать", @@ -272,13 +370,42 @@ }, "user": { "name": "Имя", + "email": "Email", + "tooltipSelectIcon": "Выберите значок", "selectAnIcon": "Выбрать иконку", - "pleaseInputYourOpenAIKey": "Введите токен OpenAI" + "pleaseInputYourOpenAIKey": "Введите токен OpenAI", + "pleaseInputYourStabilityAIKey": "Пожалуйста, введите свой токен Stability AI", + "clickToLogout": "Нажмите, чтобы выйти из текущего пользователя" + }, + "shortcuts": { + "shortcutsLabel": "Ярлыки", + "command": "Команда", + "keyBinding": "Привязка клавиш", + "addNewCommand": "Добавить новую команду", + "updateShortcutStep": "Нажмите нужную комбинацию клавиш и нажмите ENTER.", + "shortcutIsAlreadyUsed": "Этот ярлык уже используется для: {conflict}", + "resetToDefault": "Сбросить сочетания клавиш по умолчанию", + "couldNotLoadErrorMsg": "Не удалось загрузить ярлыки. Попробуйте ещё раз", + "couldNotSaveErrorMsg": "Не удалось сохранить ярлыки. Попробуйте ещё раз" + }, + "mobile": { + "personalInfo": "Личная информация", + "username": "Имя пользователя", + "usernameEmptyError": "Имя пользователя не может быть пустым", + "about": "О нас", + "pushNotifications": "Всплывающее уведомление", + "support": "Поддержка", + "joinDiscord": "Присоединяйтесь к нам в Discord", + "privacyPolicy": "Политика Конфиденциальности", + "userAgreement": "Пользовательское Соглашение" } }, "grid": { "deleteView": "Вы уверены, что хотите удалить это представление?", "createView": "Новый", + "title": { + "placeholder": "Без названия" + }, "settings": { "filter": "Фильтр", "sort": "Сортировать", @@ -291,8 +418,7 @@ "filterBy": "Фильтровать по...", "typeAValue": "Введите значение...", "layout": "Вид", - "databaseLayout": "Вид базы данных", - "Properties": "Свойства" + "databaseLayout": "Вид базы данных" }, "textFilter": { "contains": "Содержит", @@ -336,6 +462,7 @@ }, "field": { "hide": "Скрыть", + "show": "Показать", "insertLeft": "Вставить слева", "insertRight": "Вставить справа", "duplicate": "Дублировать", @@ -353,6 +480,7 @@ "numberFormat": "Формат числа", "dateFormat": "Формат даты", "includeTime": "Время", + "isRange": "Дата окончания", "dateFormatFriendly": "День Месяц, Год", "dateFormatISO": "Год-Месяц-День", "dateFormatLocal": "Месяц/День/Год", @@ -362,46 +490,73 @@ "invalidTimeFormat": "Неверный формат", "timeFormatTwelveHour": "12 часов", "timeFormatTwentyFourHour": "24 часа", + "clearDate": "Очистить дату", "addSelectOption": "Добавить вариант", "optionTitle": "Варианты", "addOption": "Добавить", "editProperty": "Редактировать свойство", - "newProperty": "Добавить колонку", - "deleteFieldPromptMessage": "Вы уверены? Свойство будет удалено" + "newProperty": "Новое свойство", + "deleteFieldPromptMessage": "Вы уверены? Свойство будет удалено", + "newColumn": "Новый столбец" + }, + "rowPage": { + "newField": "Добавить новое поле", + "fieldDragEelementTooltip": "Нажмите, чтобы открыть меню", + "showHiddenFields": { + "one": "Показать {} скрытое поле", + "many": "Показать {} скрытых поля", + "other": "Показать {} скрытых поля" + }, + "hideHiddenFields": { + "one": "Скрыть {} скрытое поле", + "many": "Скрыто {} скрытых поля", + "other": "Скрыто {} скрытых поля" + } }, "sort": { "ascending": "По возрастанию", "descending": "По убыванию", + "deleteAllSorts": "Удалить все сортировки", "addSort": "Добавить сортировку", "deleteSort": "Удалить сортировку" }, "row": { "duplicate": "Дублировать", "delete": "Удалить", + "titlePlaceholder": "Без названия", "textPlaceholder": "Пусто", "copyProperty": "Свойство скопировано", "count": "Количество", "newRow": "Новая строка", - "action": "Действия" + "action": "Действия", + "add": "Нажмите, чтобы добавить ниже", + "drag": "Перетащите для перемещения" }, "selectOption": { "create": "Создать", - "purpleColor": "Фиолетовый", + "purpleColor": "Сиреневый", "pinkColor": "Розовый", - "lightPinkColor": "Светло-розовый", + "lightPinkColor": "Фиолетовый", "orangeColor": "Оранжевый", - "yellowColor": "Желтый", + "yellowColor": "Жёлтый", "limeColor": "Ярко-зелёный", "greenColor": "Зелёный", "aquaColor": "Бирюзовый", "blueColor": "Синий", - "deleteTag": "Удалить вариант", + "deleteTag": "Удалить тег", "colorPanelTitle": "Цвета", "panelTitle": "Выберите или создайте вариант", - "searchOption": "Поиск" + "searchOption": "Поиск", + "searchOrCreateOption": "Найдите или создайте вариант...", + "createNew": "Создать новую", + "orSelectOne": "Или выберите вариант" }, "checklist": { - "addNew": "Добавить элемент" + "taskHint": "Описание задачи", + "addNew": "Добавить элемент", + "submitNewTask": "Создать", + "hideComplete": "Скрыть выполненные задачи", + "showComplete": "Показать все задачи" }, "menuName": "Сетка", "referencedGridPrefix": "Просмотр" @@ -427,13 +582,14 @@ } }, "selectionMenu": { - "outline": "Контур" + "outline": "Контур", + "codeBlock": "Блок кода" }, "plugins": { "referencedBoard": "Связанные доски", "referencedGrid": "Связанные сетки", - "referencedCalendar": "Ссылочный календарь", - "autoGeneratorMenuItemName": "Генератор OpenAI", + "referencedCalendar": "Связанные календари", + "autoGeneratorMenuItemName": "OpenAI Генератор", "autoGeneratorTitleName": "OpenAI: попросить ИИ написать что угодно...", "autoGeneratorLearnMore": "Узнать больше", "autoGeneratorGenerate": "Генерировать", @@ -444,15 +600,15 @@ "openAI": "OpenAI", "smartEditFixSpelling": "Исправить правописание", "warning": "⚠️ Ответы ИИ могут быть неправильными или неточными.", - "smartEditSummarize": "Выделить суть", - "smartEditImproveWriting": "Улучшить", + "smartEditSummarize": "Обобщить", + "smartEditImproveWriting": "Исправить написание", "smartEditMakeLonger": "Продолжить", "smartEditCouldNotFetchResult": "Не могу получить ответ от OpenAI", "smartEditCouldNotFetchKey": "Не могу получить токен OpenAI", - "smartEditDisabled": "Подключить OpenAI", + "smartEditDisabled": "OpenAI", "discardResponse": "Хотите убрать ответы ИИ?", "createInlineMathEquation": "Создать уравнение", - "toggleList": "Переключить список", + "toggleList": "Выпадающий список", "cover": { "changeCover": "Сменить обложку", "colors": "Цвета", @@ -497,10 +653,24 @@ "defaultColor": "Цвет по умолчанию" }, "image": { - "copiedToPasteBoard": "Ссылка на изображение скопирована в буфер обмена" + "copiedToPasteBoard": "Ссылка на изображение скопирована в буфер обмена", + "addAnImage": "Добавить изображение" }, "outline": { "addHeadingToCreateOutline": "Добавьте заголовки, чтобы создать оглавление." + }, + "table": { + "addAfter": "Добавить после", + "addBefore": "Добавить до", + "delete": "Удалить", + "clear": "Очистить содержимое", + "duplicate": "Дублировать", + "bgColor": "Цвет фона" + }, + "contextMenu": { + "copy": "Копировать", + "cut": "Вырезать", + "paste": "Вставить" } }, "textBlock": { @@ -519,13 +689,28 @@ "label": "URL изображения", "placeholder": "Введите URL-адрес изображения" }, + "ai": { + "label": "Сгенерировать изображение через OpenAI", + "placeholder": "Пожалуйста, введите запрос для OpenAI чтобы сгенерировать изображение" + }, + "stability_ai": { + "label": "Сгенерировать изображение через Stability AI", + "placeholder": "Пожалуйста, введите запрос для Stability AI чтобы сгенерировать изображение" + }, "support": "Ограничение размера изображения составляет 5 МБ. Поддерживаемые форматы: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Недопустимое изображение", "invalidImageSize": "Размер изображения должен быть менее 5 МБ.", "invalidImageFormat": "Формат изображения не поддерживается. Поддерживаемые форматы: JPEG, PNG, GIF, SVG", "invalidImageUrl": "Недопустимый URL-адрес изображения" - } + }, + "embedLink": { + "label": "Вставить ссылку", + "placeholder": "Вставьте или введите ссылку на изображение" + }, + "searchForAnImage": "Поиск изображения", + "pleaseInputYourOpenAIKey": "пожалуйста, введите свой токен OpenAI на странице настроек", + "pleaseInputYourStabilityAIKey": "пожалуйста, введите свой токен Stability AI на странице настроек" }, "codeBlock": { "language": { @@ -535,6 +720,9 @@ }, "inlineLink": { "placeholder": "Вставьте или введите ссылку", + "openInNewTab": "Открыть в новой вкладке", + "copyLink": "Скопировать ссылку", + "removeLink": "Удалить ссылку", "url": { "label": "URL-адрес ссылки", "placeholder": "Введите URL ссылки" @@ -543,18 +731,40 @@ "label": "Название ссылки", "placeholder": "Введите название ссылки" } + }, + "mention": { + "placeholder": "Упомяните человека, страницу или дату...", + "page": { + "label": "Ссылка на страницу", + "tooltip": "Нажмите, чтобы открыть страницу" + } + }, + "toolbar": { + "resetToDefaultFont": "Восстановить по умолчанию" + }, + "errorBlock": { + "theBlockIsNotSupported": "Текущая версия не поддерживает этот блок.", + "blockContentHasBeenCopied": "Содержимое блока скопировано." } }, "board": { + "menuName": "Доска", + "referencedBoardPrefix": "Просмотр", "column": { + "createNewCard": "Новая", + "renameGroupTooltip": "Нажмите, чтобы переименовать группу", "create_new_card": "Создать" }, - "menuName": "Доска", - "referencedBoardPrefix": "Просмотр" - }, + "showUngrouped": "Показать несгруппированные элементы", + "ungroupedButtonText": "Разгруппировать", + "ungroupedButtonTooltip": "Содержит карточки, которые не принадлежат ни к одной группе.", + "ungroupedItemsTitle": "Нажмите, чтобы добавить на доску", + "groupBy": "Сгруппировать по" + }, "calendar": { "menuName": "Календарь", - "defaultNewCalendarTitle": "Безымянный", + "defaultNewCalendarTitle": "Без названия", + "newEventButtonTooltip": "Добавить новое событие", "navigation": { "today": "Сегодня", "jumpToday": "Перейти к сегодняшнему дню", @@ -566,10 +776,14 @@ "showWeekends": "Показывать выходные", "firstDayOfWeek": "Первый день недели", "layoutDateField": "Вид календаря", - "noDateTitle": "No Date", - "noDateHint": "Unscheduled events will show up here", - "clickToAdd": "Click to add to the calendar", - "name": "Calendar layout" + "noDateTitle": "Без даты", + "noDateHint": { + "zero": "Здесь будут отображаться незапланированные мероприятия.", + "one": "{} незапланированное событие", + "other": "{} незапланированные события" + }, + "clickToAdd": "Нажмите, чтобы добавить в календарь", + "name": "Макет календаря" }, "referencedCalendarPrefix": "Вид" }, @@ -594,5 +808,217 @@ "views": { "deleteContentTitle": "Вы уверены, что хотите удалить {pageType}?", "deleteContentCaption": "если вы удалите этот {pageType}, вы сможете восстановить его из корзины." + }, + "colors": { + "custom": "Пользовательский", + "default": "По умолчанию", + "red": "Красный", + "orange": "Оранжевый", + "yellow": "Жёлтый", + "green": "Зелёный", + "blue": "Синий", + "purple": "Фиолетовый", + "pink": "Розовый", + "brown": "Коричневый", + "gray": "Серый" + }, + "emoji": { + "search": "Поиск эмодзи", + "noRecent": "Нет недавних эмодзи", + "noEmojiFound": "Эмодзи не найдено", + "filter": "Фильтр", + "random": "Случайно", + "selectSkinTone": "Выберите оттенок кожи", + "remove": "Удалить эмодзи", + "categories": { + "smileys": "Смайлики и эмоции", + "people": "Люди и тело", + "animals": "Животные и природа", + "food": "Еда, напитки", + "activities": "Деятельность", + "places": "Путешествия и места", + "objects": "Объекты", + "symbols": "Символы", + "flags": "Флаги", + "nature": "Природа", + "frequentlyUsed": "Часто используемые" + } + }, + "inlineActions": { + "noResults": "Нет результатов", + "pageReference": "Ссылка на страницу", + "date": "Дата", + "reminder": { + "groupTitle": "Напоминание", + "shortKeyword": "напомнить" + } + }, + "datePicker": { + "dateTimeFormatTooltip": "Измените формат даты и времени в настройках" + }, + "relativeDates": { + "yesterday": "Вчера", + "today": "Сегодня", + "tomorrow": "Завтра", + "oneWeek": "1 неделя" + }, + "notificationHub": { + "title": "Уведомления", + "emptyTitle": "Всё схвачено!", + "emptyBody": "Никаких ожидающих уведомлений или действий. Наслаждайтесь спокойствием.", + "tabs": { + "inbox": "Входящие", + "upcoming": "Предстоящие" + }, + "actions": { + "markAllRead": "Отметить все как прочитанное", + "showAll": "Показать всё", + "showUnreads": "Не прочитано" + }, + "filters": { + "ascending": "По возрастанию", + "descending": "По убыванию", + "groupByDate": "Сгруппировать по дате", + "showUnreadsOnly": "Показать только непрочитанные", + "resetToDefault": "Восстановить по умолчанию" + } + }, + "reminderNotification": { + "title": "Напоминание", + "message": "Не забудьте проверить это, прежде чем забыть!", + "tooltipDelete": "Удалить", + "tooltipMarkRead": "Отметить как прочитанное", + "tooltipMarkUnread": "Отметить как непрочитанное" + }, + "findAndReplace": { + "find": "Найти", + "previousMatch": "Предыдущее совпадение", + "nextMatch": "Следующее совпадение", + "close": "Закрыть", + "replace": "Заменить", + "replaceAll": "Заменить всё", + "noResult": "Нет результатов", + "caseSensitive": "С учетом регистра" + }, + "error": { + "weAreSorry": "Мы сожалеем", + "loadingViewError": "У нас возникли проблемы при загрузке этого представления. Пожалуйста, проверьте ваше интернет-соединение, обновите приложение, и не стесняйтесь обратиться к команде, если проблема не исчезнет." + }, + "editor": { + "bold": "Жирный", + "bulletedList": "Маркированный список", + "checkbox": "Чекбокс", + "embedCode": "Встроить код", + "heading1": "H1", + "heading2": "H2", + "heading3": "Н3", + "highlight": "Выделить", + "color": "Цвет", + "image": "Изображение", + "italic": "Курсив", + "link": "Ссылка", + "numberedList": "Нумерованный список", + "quote": "Цитировать", + "strikethrough": "Зачёркнутый", + "text": "Текст", + "underline": "Подчёркнутый", + "fontColorDefault": "По умолчанию", + "fontColorGray": "Серый", + "fontColorBrown": "Коричневый", + "fontColorOrange": "Оранжевый", + "fontColorYellow": "Жёлтый", + "fontColorGreen": "Зелёный", + "fontColorBlue": "Синий", + "fontColorPurple": "Фиолетовый", + "fontColorPink": "Розовый", + "fontColorRed": "Красный", + "backgroundColorDefault": "Фон по умолчанию", + "backgroundColorGray": "Серый фон", + "backgroundColorBrown": "Коричневый фон", + "backgroundColorOrange": "Оранжевый фон", + "backgroundColorYellow": "Жёлтый фон", + "backgroundColorGreen": "Зелёный фон", + "backgroundColorBlue": "Синий фон", + "backgroundColorPurple": "Фиолетовый фон", + "backgroundColorPink": "Розовый фон", + "backgroundColorRed": "Красный фон", + "done": "Готово", + "cancel": "Отмена", + "tint1": "Оттенок 1", + "tint2": "Оттенок 2", + "tint3": "Оттенок 3", + "tint4": "Оттенок 4", + "tint5": "Оттенок 5", + "tint6": "Оттенок 6", + "tint7": "Оттенок 7", + "tint8": "Оттенок 8", + "tint9": "Оттенок 9", + "lightLightTint1": "Фиолетовый", + "lightLightTint2": "Розовый", + "lightLightTint3": "Светло-розовый", + "lightLightTint4": "Оранжевый", + "lightLightTint5": "Жёлтый", + "lightLightTint6": "Лайм", + "lightLightTint7": "Зелёный", + "lightLightTint8": "Бирюзовый", + "lightLightTint9": "Синий", + "urlHint": "URL", + "mobileHeading1": "Заголовок 1", + "mobileHeading2": "Заголовок 2", + "mobileHeading3": "Заголовок 3", + "textColor": "Цвет текста", + "backgroundColor": "Фоновый цвет", + "addYourLink": "Добавьте свою ссылку", + "openLink": "Открыть ссылку", + "copyLink": "Скопировать ссылку", + "removeLink": "Удалить ссылку", + "editLink": "Изменить ссылку", + "linkText": "Текст", + "linkTextHint": "Пожалуйста, введите текст", + "linkAddressHint": "Пожалуйста, введите URL", + "highlightColor": "Цвет выделения", + "clearHighlightColor": "Сбросить цвет выделения", + "customColor": "Пользовательский цвет", + "hexValue": "Hex значение", + "opacity": "Непрозрачность", + "resetToDefaultColor": "Сбросить цвет по умолчанию", + "ltr": "Слева направо", + "rtl": "Справа налево", + "auto": "Авто", + "cut": "Вырезать", + "copy": "Копировать", + "paste": "Вставить", + "find": "Найти", + "previousMatch": "Предыдущее совпадение", + "nextMatch": "Следующее совпадение", + "closeFind": "Закрыть", + "replace": "Заменить", + "replaceAll": "Заменить всё", + "regex": "Регулярное выражение", + "caseSensitive": "С учетом регистра", + "uploadImage": "Загрузить изображение", + "urlImage": "URL-изображение", + "incorrectLink": "Неверная ссылка", + "upload": "Загрузить", + "chooseImage": "Выберите изображение", + "loading": "Загрузка", + "imageLoadFailed": "Не удалось загрузить изображение", + "divider": "Разделитель", + "table": "Таблица", + "colAddBefore": "Добавить до", + "rowAddBefore": "Добавить до", + "colAddAfter": "Добавить после", + "rowAddAfter": "Добавить после", + "colRemove": "Удалить столбец", + "rowRemove": "Удалить строку", + "colDuplicate": "Дублировать", + "rowDuplicate": "Дублировать", + "colClear": "Очистить контент", + "rowClear": "Очистить содержимое строки", + "slashPlaceHolder": "Введите /, чтобы вставить блок, или начните вводить текст." + }, + "favorite": { + "noFavorite": "Нет избраной страницы", + "noFavoriteHintText": "Проведите по странице влево, чтобы добавить ее в избранное." } } \ No newline at end of file diff --git a/frontend/resources/translations/sv.json b/frontend/resources/translations/sv-SE.json similarity index 98% rename from frontend/resources/translations/sv.json rename to frontend/resources/translations/sv-SE.json index fd3f43c45684..b6f838065f8b 100644 --- a/frontend/resources/translations/sv.json +++ b/frontend/resources/translations/sv-SE.json @@ -159,9 +159,7 @@ "editContact": "Redigera kontakt" }, "button": { - "OK": "OK", - "Done": "Gjort", - "Cancel": "Avbryt", + "done": "Gjort", "signIn": "Logga in", "signOut": "Logga ut", "complete": "Slutfört", @@ -177,8 +175,10 @@ "edit": "Redigera", "delete": "Radera", "duplicate": "Duplicera", - "done": "Gjort", - "putback": "Ställ tillbaka" + "putback": "Ställ tillbaka", + "OK": "OK", + "Done": "Gjort", + "Cancel": "Avbryt" }, "label": { "welcome": "Välkommen!", @@ -233,7 +233,7 @@ }, "theme": "Tema", "builtInsLabel": "Inbyggda teman", - "pluginsLabel": "Plugins" + "pluginsLabel": "Plugin" }, "files": { "copy": "Kopiera", @@ -253,7 +253,7 @@ "open": "Öppen", "openFolder": "Öppna en befintlig mapp", "openFolderDesc": "Läs och skriv det till din befintliga AppFlowy-mapp", - "folderHintText": "mapp namn", + "folderHintText": "mappnamn", "location": "Skapar en ny mapp", "locationDesc": "Välj ett namn för din AppFlowy-datamapp", "browser": "Bläddra", @@ -455,12 +455,12 @@ "createInlineMathEquation": "Skapa ekvation", "toggleList": "Växla lista", "cover": { - "changeCover": "Byta omslag", + "changeCover": "Byt omslag", "colors": "Färger", "images": "Bilder", "clearAll": "Rensa alla", "abstract": "Abstrakt", - "addCover": "Lägg till lock", + "addCover": "Lägg till omslag", "addLocalImage": "Lägg till lokal bild", "invalidImageUrl": "Ogiltig bildadress", "failedToAddImageToGallery": "Det gick inte att lägga till bild i galleriet", @@ -547,11 +547,11 @@ } }, "board": { + "menuName": "Tavla", + "referencedBoardPrefix": "Utsikt över", "column": { "create_new_card": "Nytt" - }, - "menuName": "Tavla", - "referencedBoardPrefix": "Utsikt över" + } }, "calendar": { "menuName": "Kalender", @@ -568,9 +568,9 @@ "firstDayOfWeek": "Börja veckan på", "layoutDateField": "Layoutkalender av", "noDateTitle": "Inget datum", - "noDateHint": "Icke schemalagda händelser kommer att dyka upp här", "clickToAdd": "Klicka för att lägga till i kalendern", - "name": "Kalenderlayout" + "name": "Kalenderlayout", + "noDateHint": "Icke schemalagda händelser kommer att dyka upp här" }, "referencedCalendarPrefix": "Utsikt över" }, @@ -596,4 +596,4 @@ "deleteContentTitle": "Är du säker på att du vill ta bort {pageType}?", "deleteContentCaption": "om du tar bort denna {pageType} kan du återställa den från papperskorgen." } -} \ No newline at end of file +} diff --git a/frontend/resources/translations/tr-TR.json b/frontend/resources/translations/tr-TR.json index a6b5e8310550..9a147d3277cc 100644 --- a/frontend/resources/translations/tr-TR.json +++ b/frontend/resources/translations/tr-TR.json @@ -159,9 +159,7 @@ "editContact": "Kişiyi Düzenle" }, "button": { - "OK": "TAMAM", - "Done": "Tamamlamak", - "Cancel": "İptal", + "done": "Tamamlamak", "signIn": "Oturum Aç", "signOut": "Oturum Kapat", "complete": "Tamamlandı", @@ -177,8 +175,10 @@ "edit": "Düzenlemek", "delete": "Silmek", "duplicate": "Kopyalamak", - "done": "Tamamlamak", - "putback": "Geri koy" + "putback": "Geri koy", + "OK": "TAMAM", + "Done": "Tamamlamak", + "Cancel": "İptal" }, "label": { "welcome": "Merhaba!", @@ -546,11 +546,11 @@ } }, "board": { + "menuName": "Pano", + "referencedBoardPrefix": "görünümü", "column": { "create_new_card": "Yeni" - }, - "menuName": "Pano", - "referencedBoardPrefix": "görünümü" + } }, "calendar": { "menuName": "Takvim", @@ -567,9 +567,9 @@ "firstDayOfWeek": "hafta başla", "layoutDateField": "Yerleşim takvimi", "noDateTitle": "Tarih yok", - "noDateHint": "Planlanmamış etkinlikler burada gösterilir", "clickToAdd": "Takvime eklemek için tıklayın", - "name": "Takvim düzeni" + "name": "Takvim düzeni", + "noDateHint": "Planlanmamış etkinlikler burada gösterilir" }, "referencedCalendarPrefix": "görünümü" }, diff --git a/frontend/resources/translations/vi-VN.json b/frontend/resources/translations/vi-VN.json index 4710e36778ca..324d13de6e46 100644 --- a/frontend/resources/translations/vi-VN.json +++ b/frontend/resources/translations/vi-VN.json @@ -68,10 +68,10 @@ } }, "board": { + "menuName": "Bảng", "column": { "create_new_card": "Mới" - }, - "menuName": "Bảng" + } }, "calendar": { "menuName": "Lịch", diff --git a/frontend/resources/translations/zh-CN.json b/frontend/resources/translations/zh-CN.json index b5289f4c620a..8e223b72c33c 100644 --- a/frontend/resources/translations/zh-CN.json +++ b/frontend/resources/translations/zh-CN.json @@ -159,9 +159,7 @@ "editContact": "编辑联系人" }, "button": { - "OK": "确认", - "Done": "完成", - "Cancel": "取消", + "done": "完毕", "signIn": "登录", "signOut": "登出", "complete": "完成", @@ -177,8 +175,10 @@ "edit": "编辑", "delete": "删除", "duplicate": "复制", - "done": "完毕", - "putback": "放回去" + "putback": "放回去", + "OK": "确认", + "Done": "完成", + "Cancel": "取消" }, "label": { "welcome": "欢迎!", @@ -547,11 +547,11 @@ } }, "board": { + "menuName": "看板", + "referencedBoardPrefix": "视图", "column": { "create_new_card": "新建" - }, - "menuName": "看板", - "referencedBoardPrefix": "视图" + } }, "calendar": { "menuName": "日历", @@ -568,9 +568,9 @@ "firstDayOfWeek": "一周开始于", "layoutDateField": "以……为日历布局", "noDateTitle": "没有日期", - "noDateHint": "计划外事件将显示在此处", "clickToAdd": "单击以添加到日历", - "name": "日历布局" + "name": "日历布局", + "noDateHint": "计划外事件将显示在此处" }, "referencedCalendarPrefix": "视图" }, diff --git a/frontend/resources/translations/zh-TW.json b/frontend/resources/translations/zh-TW.json index dd8677419f82..dad5a435b789 100644 --- a/frontend/resources/translations/zh-TW.json +++ b/frontend/resources/translations/zh-TW.json @@ -159,9 +159,7 @@ "editContact": "編輯聯絡人" }, "button": { - "OK": "OK", - "Done": "完畢", - "Cancel": "取消", + "done": "完畢", "signIn": "登入", "signOut": "登出", "complete": "完成", @@ -177,8 +175,10 @@ "edit": "編輯", "delete": "刪除", "duplicate": "複製", - "done": "完畢", - "putback": "放回去" + "putback": "放回去", + "OK": "OK", + "Done": "完畢", + "Cancel": "取消" }, "label": { "welcome": "歡迎!", @@ -547,11 +547,11 @@ } }, "board": { + "menuName": "看板", + "referencedBoardPrefix": "視圖", "column": { "create_new_card": "建立" - }, - "menuName": "看板", - "referencedBoardPrefix": "視圖" + } }, "calendar": { "menuName": "月曆", @@ -568,9 +568,9 @@ "firstDayOfWeek": "一週的第一天", "layoutDateField": "排列方式", "noDateTitle": "未註明日期的", - "noDateHint": "未安排的事件將顯示在這裡", "clickToAdd": "點擊添加到日曆", - "name": "日曆佈局" + "name": "日曆佈局", + "noDateHint": "未安排的事件將顯示在這裡" }, "referencedCalendarPrefix": "視圖" }, diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 041ccd56c116..7770891ec326 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -115,21 +115,27 @@ dependencies = [ "libc", ] -[[package]] -name = "ansi_term" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" -dependencies = [ - "winapi", -] - [[package]] name = "anyhow" version = "1.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" +[[package]] +name = "app-error" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=fe977fc8285addd5386e940738cdffbbda9eb44e#fe977fc8285addd5386e940738cdffbbda9eb44e" +dependencies = [ + "anyhow", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "url", + "uuid", +] + [[package]] name = "arrayvec" version = "0.5.2" @@ -169,7 +175,7 @@ checksum = "7150fb5d9cc4eb0184af43ce75a89620dc3747d3c816e8b0ba200682d0155c05" dependencies = [ "async-convert", "backoff", - "base64 0.21.3", + "base64 0.21.5", "derive_builder", "futures", "rand 0.8.5", @@ -208,9 +214,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.73" +version = "0.1.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc00ceb34980c03614e35a3a4e218276a0a824e911d07651cd0d858a51e8c0f0" +checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9" dependencies = [ "proc-macro2", "quote", @@ -366,9 +372,9 @@ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] name = "base64" -version = "0.21.3" +version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "414dcefbc63d77c526a76b3afcf6fbb9b5e2791c19c3aa2297733208750c6e53" +checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" [[package]] name = "base64ct" @@ -660,9 +666,11 @@ dependencies = [ [[package]] name = "client-api" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c87d0f05e988a02e9272a42722b304289be320e4#c87d0f05e988a02e9272a42722b304289be320e4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=fe977fc8285addd5386e940738cdffbbda9eb44e#fe977fc8285addd5386e940738cdffbbda9eb44e" dependencies = [ "anyhow", + "app-error", + "async-trait", "bytes", "collab", "collab-entity", @@ -675,9 +683,10 @@ dependencies = [ "mime", "mime_guess", "parking_lot", + "prost", "realtime-entity", "reqwest", - "scraper", + "scraper 0.17.1", "serde", "serde_json", "serde_repr", @@ -721,10 +730,11 @@ dependencies = [ [[package]] name = "collab" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5e#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ "anyhow", "async-trait", + "bincode", "bytes", "lib0", "parking_lot", @@ -740,11 +750,11 @@ dependencies = [ [[package]] name = "collab-database" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5e#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ "anyhow", "async-trait", - "base64 0.21.3", + "base64 0.21.5", "chrono", "collab", "collab-derive", @@ -770,7 +780,7 @@ dependencies = [ [[package]] name = "collab-derive" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5e#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ "proc-macro2", "quote", @@ -782,7 +792,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5e#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ "anyhow", "collab", @@ -802,7 +812,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5e#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ "anyhow", "bytes", @@ -816,7 +826,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5e#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ "anyhow", "chrono", @@ -858,8 +868,9 @@ dependencies = [ [[package]] name = "collab-persistence" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5e#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ + "anyhow", "async-trait", "bincode", "chrono", @@ -879,7 +890,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5e#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ "anyhow", "async-trait", @@ -906,7 +917,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ff10abd5e#ff10abd5e41b9a7d118cbb79f8c2bf9ac27c0ded" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b8097fa891bdbb5826d6e480460c50ab66d26881#b8097fa891bdbb5826d6e480460c50ab66d26881" dependencies = [ "anyhow", "collab", @@ -948,10 +959,11 @@ dependencies = [ [[package]] name = "console-api" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2895653b4d9f1538a83970077cb01dfc77a4810524e51a110944688e916b18e" +checksum = "fd326812b3fd01da5bb1af7d340d0d555fd3d4b641e7f1dfcf5962a902952787" dependencies = [ + "futures-core", "prost", "prost-types", "tonic", @@ -960,14 +972,14 @@ dependencies = [ [[package]] name = "console-subscriber" -version = "0.1.10" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4cf42660ac07fcebed809cfe561dd8730bcd35b075215e6479c516bcd0d11cb" +checksum = "7481d4c57092cd1c19dd541b92bdce883de840df30aa5d03fd48a3935c01842e" dependencies = [ "console-api", "crossbeam-channel", "crossbeam-utils", - "futures", + "futures-task", "hdrhistogram", "humantime", "prost-types", @@ -979,7 +991,7 @@ dependencies = [ "tonic", "tracing", "tracing-core", - "tracing-subscriber 0.3.17", + "tracing-subscriber", ] [[package]] @@ -1138,7 +1150,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.8.0", + "phf 0.11.2", "smallvec", ] @@ -1234,7 +1246,6 @@ dependencies = [ "flowy-server-config", "lazy_static", "lib-dispatch", - "log", "parking_lot", "protobuf", "serde", @@ -1265,9 +1276,10 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c87d0f05e988a02e9272a42722b304289be320e4#c87d0f05e988a02e9272a42722b304289be320e4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=fe977fc8285addd5386e940738cdffbbda9eb44e#fe977fc8285addd5386e940738cdffbbda9eb44e" dependencies = [ "anyhow", + "app-error", "chrono", "collab-entity", "serde", @@ -1658,6 +1670,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.0.27" @@ -1734,6 +1752,7 @@ name = "flowy-core" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.21.5", "bytes", "client-api", "collab", @@ -1759,6 +1778,7 @@ dependencies = [ "flowy-task", "flowy-user", "flowy-user-deps", + "futures", "futures-core", "lib-dispatch", "lib-infra", @@ -1771,6 +1791,7 @@ dependencies = [ "tokio-stream", "tracing", "uuid", + "walkdir", ] [[package]] @@ -1812,6 +1833,7 @@ dependencies = [ "lazy_static", "lib-dispatch", "lib-infra", + "lru", "nanoid", "parking_lot", "protobuf", @@ -1890,9 +1912,11 @@ dependencies = [ "indexmap 1.9.3", "lib-dispatch", "lib-infra", + "lru", "nanoid", "parking_lot", "protobuf", + "scraper 0.18.1", "serde", "serde_json", "strum_macros 0.21.1", @@ -1900,8 +1924,9 @@ dependencies = [ "tokio", "tokio-stream", "tracing", - "tracing-subscriber 0.3.17", + "tracing-subscriber", "uuid", + "validator", ] [[package]] @@ -1910,7 +1935,7 @@ version = "0.1.0" dependencies = [ "aes-gcm", "anyhow", - "base64 0.21.3", + "base64 0.21.5", "hmac", "pbkdf2 0.12.2", "rand 0.8.5", @@ -1938,6 +1963,7 @@ dependencies = [ "serde_json", "serde_repr", "thiserror", + "tokio", "tokio-postgres", "url", "validator", @@ -2025,6 +2051,7 @@ dependencies = [ "hex", "hyper", "lazy_static", + "lib-dispatch", "lib-infra", "mime_guess", "parking_lot", @@ -2039,7 +2066,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", - "tracing-subscriber 0.3.17", + "tracing-subscriber", "url", "uuid", "yrs", @@ -2108,7 +2135,7 @@ name = "flowy-user" version = "0.1.0" dependencies = [ "anyhow", - "base64 0.21.3", + "base64 0.21.5", "bytes", "chrono", "collab", @@ -2134,7 +2161,6 @@ dependencies = [ "lazy_static", "lib-dispatch", "lib-infra", - "log", "nanoid", "once_cell", "parking_lot", @@ -2226,9 +2252,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40" +checksum = "da0290714b38af9b4a7b094b8a37086d1b4e61f2df9122c3cad2577669145335" dependencies = [ "futures-channel", "futures-core", @@ -2241,9 +2267,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" +checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" dependencies = [ "futures-core", "futures-sink", @@ -2251,15 +2277,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" +checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" [[package]] name = "futures-executor" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" +checksum = "0f4fb8693db0cf099eadcca0efe2a5a22e4550f98ed16aba6c48700da29597bc" dependencies = [ "futures-core", "futures-task", @@ -2279,15 +2305,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" +checksum = "8bf34a163b5c4c52d0478a4d757da8fb65cabef42ba90515efee0f6f9fa45aaa" [[package]] name = "futures-macro" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" +checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" dependencies = [ "proc-macro2", "quote", @@ -2296,15 +2322,15 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" +checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" [[package]] name = "futures-task" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" +checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2" [[package]] name = "futures-timer" @@ -2314,9 +2340,9 @@ checksum = "e64b03909df88034c26dc1547e8970b91f98bdb65165d6a4e9110d94263dbb2c" [[package]] name = "futures-util" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" +checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" dependencies = [ "futures-channel", "futures-core", @@ -2441,7 +2467,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c87d0f05e988a02e9272a42722b304289be320e4#c87d0f05e988a02e9272a42722b304289be320e4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=fe977fc8285addd5386e940738cdffbbda9eb44e#fe977fc8285addd5386e940738cdffbbda9eb44e" dependencies = [ "anyhow", "futures-util", @@ -2457,9 +2483,10 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c87d0f05e988a02e9272a42722b304289be320e4#c87d0f05e988a02e9272a42722b304289be320e4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=fe977fc8285addd5386e940738cdffbbda9eb44e#fe977fc8285addd5386e940738cdffbbda9eb44e" dependencies = [ "anyhow", + "app-error", "jsonwebtoken", "lazy_static", "reqwest", @@ -2817,7 +2844,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c87d0f05e988a02e9272a42722b304289be320e4#c87d0f05e988a02e9272a42722b304289be320e4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=fe977fc8285addd5386e940738cdffbbda9eb44e#fe977fc8285addd5386e940738cdffbbda9eb44e" dependencies = [ "anyhow", "reqwest", @@ -2897,7 +2924,7 @@ version = "8.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" dependencies = [ - "base64 0.21.3", + "base64 0.21.5", "pem", "ring", "serde", @@ -2942,8 +2969,8 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "log", "nanoid", + "parking_lot", "pin-project", "protobuf", "serde", @@ -2976,15 +3003,13 @@ version = "0.1.0" dependencies = [ "chrono", "lazy_static", - "log", "serde", "serde_json", "tracing", "tracing-appender", "tracing-bunyan-formatter", "tracing-core", - "tracing-log", - "tracing-subscriber 0.2.25", + "tracing-subscriber", ] [[package]] @@ -3000,9 +3025,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.147" +version = "0.2.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" +checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" [[package]] name = "libloading" @@ -3087,11 +3112,11 @@ checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] name = "lru" -version = "0.10.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "718e8fae447df0c7e1ba7f5189829e63fd536945c8988d61444c19039f16b670" +checksum = "1efa59af2ddfad1854ae27d75009d538d0998b4b2fd47083e743ac1a10e46c60" dependencies = [ - "hashbrown 0.13.2", + "hashbrown 0.14.0", ] [[package]] @@ -3114,15 +3139,6 @@ dependencies = [ "tendril", ] -[[package]] -name = "matchers" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f099785f7595cc4b4553a174ce30dd7589ef93391ff414dbb67f62392b9e0ce1" -dependencies = [ - "regex-automata 0.1.10", -] - [[package]] name = "matchers" version = "0.1.0" @@ -3248,15 +3264,21 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" dependencies = [ "libc", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys", ] +[[package]] +name = "multimap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" + [[package]] name = "nanoid" version = "0.4.0" @@ -3619,13 +3641,23 @@ dependencies = [ "sha2", ] +[[package]] +name = "petgraph" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d3afd2628e69da2be385eb6f2fd57c8ac7977ceeff6dc166ff1657b0e386a9" +dependencies = [ + "fixedbitset", + "indexmap 2.0.0", +] + [[package]] name = "phf" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros", + "phf_macros 0.8.0", "phf_shared 0.8.0", "proc-macro-hack", ] @@ -3645,6 +3677,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" dependencies = [ + "phf_macros 0.11.2", "phf_shared 0.11.2", ] @@ -3712,6 +3745,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", + "proc-macro2", + "quote", + "syn 2.0.31", +] + [[package]] name = "phf_shared" version = "0.8.0" @@ -3795,7 +3841,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49b6c5ef183cd3ab4ba005f1ca64c21e8bd97ce4699cfea9e8d9a2c4958ca520" dependencies = [ - "base64 0.21.3", + "base64 0.21.5", "byteorder", "bytes", "fallible-iterator", @@ -3899,32 +3945,54 @@ dependencies = [ [[package]] name = "prost" -version = "0.11.9" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" +checksum = "f4fdd22f3b9c31b53c060df4a0613a1c7f062d4115a2b984dd15b1858f7e340d" dependencies = [ "bytes", "prost-derive", ] +[[package]] +name = "prost-build" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bdf592881d821b83d471f8af290226c8d51402259e9bb5be7f9f8bdebbb11ac" +dependencies = [ + "bytes", + "heck 0.4.1", + "itertools 0.11.0", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.31", + "tempfile", + "which", +] + [[package]] name = "prost-derive" -version = "0.11.9" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" +checksum = "265baba7fabd416cf5078179f7d2cbeca4ce7a9041111900675ea7c4cb8a4c32" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.11.0", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.31", ] [[package]] name = "prost-types" -version = "0.11.9" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "213622a1460818959ac1181aaeb2dc9c7f63df720db7d788b3e24eacd1983e13" +checksum = "e081b29f63d83a4bc75cfc9f3fe424f9156cf92d8a4f0c9407cce9a1b67327cf" dependencies = [ "prost", ] @@ -4253,13 +4321,20 @@ dependencies = [ [[package]] name = "realtime-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c87d0f05e988a02e9272a42722b304289be320e4#c87d0f05e988a02e9272a42722b304289be320e4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=fe977fc8285addd5386e940738cdffbbda9eb44e#fe977fc8285addd5386e940738cdffbbda9eb44e" dependencies = [ + "anyhow", + "bincode", "bytes", "collab", "collab-entity", + "database-entity", + "prost", + "prost-build", + "protoc-bin-vendored", "serde", "serde_json", + "tokio-tungstenite", ] [[package]] @@ -4365,7 +4440,7 @@ version = "0.11.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e9ad3fe7488d7e34558a2033d45a0c90b72d97b4f80705666fea71472e2e6a1" dependencies = [ - "base64 0.21.3", + "base64 0.21.5", "bytes", "cookie", "cookie_store", @@ -4600,7 +4675,7 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d3987094b1d07b653b7dfdc3f70ce9a1da9c51ac18c1b06b662e4f9a0e9f4b2" dependencies = [ - "base64 0.21.3", + "base64 0.21.5", ] [[package]] @@ -4702,6 +4777,22 @@ dependencies = [ "tendril", ] +[[package]] +name = "scraper" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585480e3719b311b78a573db1c9d9c4c1f8010c2dee4cc59c2efe58ea4dbc3e1" +dependencies = [ + "ahash 0.8.3", + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "once_cell", + "selectors", + "tendril", +] + [[package]] name = "sct" version = "0.7.0" @@ -4793,9 +4884,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.105" +version = "1.0.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693151e1ac27563d6dbcec9dee9fbd5da8539b20fa14ad3752b2e6d363ace360" +checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" dependencies = [ "itoa", "ryu", @@ -4874,9 +4965,10 @@ dependencies = [ [[package]] name = "shared_entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c87d0f05e988a02e9272a42722b304289be320e4#c87d0f05e988a02e9272a42722b304289be320e4" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=fe977fc8285addd5386e940738cdffbbda9eb44e#fe977fc8285addd5386e940738cdffbbda9eb44e" dependencies = [ "anyhow", + "app-error", "collab-entity", "database-entity", "gotrue-entity", @@ -4998,9 +5090,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.3" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2538b18701741680e0322a2302176d3253a35388e2e62f172f64f4f16605f877" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" dependencies = [ "libc", "windows-sys", @@ -5383,9 +5475,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.32.0" +version = "1.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17ed6077ed6cd6c74735e21f37eb16dc3935f96878b1fe961074089cc80893f9" +checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9" dependencies = [ "backtrace", "bytes", @@ -5395,7 +5487,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.5.3", + "socket2 0.5.5", "tokio-macros", "tracing", "windows-sys", @@ -5413,9 +5505,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", @@ -5452,7 +5544,7 @@ dependencies = [ "postgres-protocol", "postgres-types", "rand 0.8.5", - "socket2 0.5.3", + "socket2 0.5.5", "tokio", "tokio-util", "whoami", @@ -5530,16 +5622,15 @@ dependencies = [ [[package]] name = "tonic" -version = "0.9.2" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3082666a3a6433f7f511c7192923fa1fe07c69332d3c6a2e6bb040b569199d5a" +checksum = "d560933a0de61cf715926b9cac824d4c883c2c43142f787595e48280c40a1d0e" dependencies = [ + "async-stream", "async-trait", "axum", - "base64 0.21.3", + "base64 0.21.5", "bytes", - "futures-core", - "futures-util", "h2", "http", "http-body", @@ -5590,11 +5681,10 @@ checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" [[package]] name = "tracing" -version = "0.1.37" +version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ - "cfg-if", "log", "pin-project-lite", "tracing-attributes", @@ -5603,20 +5693,20 @@ dependencies = [ [[package]] name = "tracing-appender" -version = "0.1.2" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9965507e507f12c8901432a33e31131222abac31edd90cabbcf85cf544b7127a" +checksum = "09d48f71a791638519505cefafe162606f706c25592e4bde4d97600c0195312e" dependencies = [ - "chrono", "crossbeam-channel", - "tracing-subscriber 0.2.25", + "time", + "tracing-subscriber", ] [[package]] name = "tracing-attributes" -version = "0.1.26" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", @@ -5625,26 +5715,27 @@ dependencies = [ [[package]] name = "tracing-bunyan-formatter" -version = "0.2.6" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c408910c9b7eabc0215fe2b4a89f8ec95581a91cea1f7619f7c78caf14cbc2a1" +checksum = "b5c266b9ac83dedf0e0385ad78514949e6d89491269e7065bee51d2bb8ec7373" dependencies = [ - "chrono", + "ahash 0.8.3", "gethostname", "log", "serde", "serde_json", + "time", "tracing", "tracing-core", "tracing-log", - "tracing-subscriber 0.2.25", + "tracing-subscriber", ] [[package]] name = "tracing-core" -version = "0.1.31" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", "valuable", @@ -5671,44 +5762,25 @@ dependencies = [ "tracing-core", ] -[[package]] -name = "tracing-subscriber" -version = "0.2.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0d2eaa99c3c2e41547cfa109e910a68ea03823cccad4a0525dcbc9b01e8c71" -dependencies = [ - "ansi_term", - "chrono", - "lazy_static", - "matchers 0.0.1", - "regex", - "serde", - "serde_json", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", - "tracing-serde", -] - [[package]] name = "tracing-subscriber" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77" dependencies = [ - "matchers 0.1.0", + "matchers", "nu-ansi-term", "once_cell", "regex", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -5888,9 +5960,9 @@ checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] name = "uuid" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" +checksum = "88ad59a7560b41a70d191093a945f0b87bc1deeda46fb237479708a1d6b6cdfc" dependencies = [ "getrandom 0.2.10", "serde", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 1aaf419f157c..5de9fe026d3e 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -26,6 +26,7 @@ members = [ "flowy-ai", "flowy-date", ] +resolver = "2" [workspace.dependencies] lib-dispatch = { workspace = true, path = "lib-dispatch" } @@ -52,6 +53,22 @@ flowy-storage = { workspace = true, path = "flowy-storage" } collab-integrate = { workspace = true, path = "collab-integrate" } flowy-ai = { workspace = true, path = "flowy-ai" } flowy-date = { workspace = true, path = "flowy-date" } +anyhow = "1.0.75" +tracing = "0.1.40" +bytes = "1.5.0" +serde_json = "1.0.108" +serde = "1.0.108" +protobuf = { version = "2.28.0" } +diesel = { version = "1.4.8", features = ["sqlite", "chrono"] } +uuid = { version = "1.5.0", features = ["serde", "v4"] } +serde_repr = "0.1" +parking_lot = "0.12" +futures = "0.3.29" +tokio = "1.34.0" +tokio-stream = "0.1.14" +async-trait = "0.1.74" +chrono = { version = "0.4.31", default-features = false, features = ["clock"] } +lru = "0.12.0" [profile.dev] opt-level = 0 @@ -82,7 +99,7 @@ incremental = false # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "c87d0f05e988a02e9272a42722b304289be320e4" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "fe977fc8285addd5386e940738cdffbbda9eb44e" } # Please use the following script to update collab. # Working directory: frontend # @@ -92,11 +109,11 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "c87 # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "ff10abd5e" } -collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "ff10abd5e" } -collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "ff10abd5e" } -collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "ff10abd5e" } -collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "ff10abd5e" } -collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "ff10abd5e" } -collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "ff10abd5e" } -collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "ff10abd5e" } +collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b8097fa891bdbb5826d6e480460c50ab66d26881" } +collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b8097fa891bdbb5826d6e480460c50ab66d26881" } +collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b8097fa891bdbb5826d6e480460c50ab66d26881" } +collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b8097fa891bdbb5826d6e480460c50ab66d26881" } +collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b8097fa891bdbb5826d6e480460c50ab66d26881" } +collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b8097fa891bdbb5826d6e480460c50ab66d26881" } +collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b8097fa891bdbb5826d6e480460c50ab66d26881" } +collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b8097fa891bdbb5826d6e480460c50ab66d26881" } diff --git a/frontend/rust-lib/collab-integrate/Cargo.toml b/frontend/rust-lib/collab-integrate/Cargo.toml index d801024469f0..b0ba081ef3f4 100644 --- a/frontend/rust-lib/collab-integrate/Cargo.toml +++ b/frontend/rust-lib/collab-integrate/Cargo.toml @@ -13,14 +13,14 @@ collab-database = { version = "0.1.0" } collab-plugins = { version = "0.1.0" } collab-document = { version = "0.1.0" } collab-entity = { version = "0.1.0" } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -anyhow = "1.0" -tracing = "0.1" -parking_lot = "0.12.1" -futures = "0.3" -async-trait = "0.1.73" -tokio = {version = "1.26", features = ["sync"]} +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +tracing.workspace = true +parking_lot.workspace = true +futures.workspace = true +async-trait.workspace = true +tokio = { workspace = true, features = ["sync"]} lib-infra = { path = "../../../shared-lib/lib-infra" } [features] diff --git a/frontend/rust-lib/collab-integrate/src/collab_builder.rs b/frontend/rust-lib/collab-integrate/src/collab_builder.rs index 42d8eee6256a..b17d6b44604f 100644 --- a/frontend/rust-lib/collab-integrate/src/collab_builder.rs +++ b/frontend/rust-lib/collab-integrate/src/collab_builder.rs @@ -41,10 +41,7 @@ pub enum CollabPluginContext { pub trait CollabStorageProvider: Send + Sync + 'static { fn storage_source(&self) -> CollabSource; - fn get_plugins( - &self, - context: CollabPluginContext, - ) -> Fut<Vec<Arc<dyn collab::core::collab_plugin::CollabPlugin>>>; + fn get_plugins(&self, context: CollabPluginContext) -> Fut<Vec<Arc<dyn CollabPlugin>>>; fn is_sync_enabled(&self) -> bool; } @@ -198,6 +195,8 @@ impl AppFlowyCollabBuilder { { let cloud_storage_type = self.cloud_storage.read().await.storage_source(); let collab_object = self.collab_object(uid, object_id, object_type)?; + let span = tracing::span!(tracing::Level::TRACE, "collab_builder", object_id = %object_id); + let _enter = span.enter(); match cloud_storage_type { CollabSource::AFCloud => { #[cfg(feature = "appflowy_cloud_integrate")] @@ -262,6 +261,7 @@ impl AppFlowyCollabBuilder { } collab.lock().initialize(); + trace!("collab initialized: {}", object_id); Ok(collab) } } diff --git a/frontend/rust-lib/dart-ffi/Cargo.toml b/frontend/rust-lib/dart-ffi/Cargo.toml index 3000f5e92219..a719c3e54bdc 100644 --- a/frontend/rust-lib/dart-ffi/Cargo.toml +++ b/frontend/rust-lib/dart-ffi/Cargo.toml @@ -14,23 +14,23 @@ crate-type = ["staticlib"] [dependencies] allo-isolate = { version = "^0.1", features = ["catch-unwind"] } byteorder = { version = "1.4.3" } -protobuf = { version = "2.28.0" } -tokio = { version = "1.26", features = ["full", "rt-multi-thread", "tracing"] } -log = "0.4.17" -serde = { version = "1.0", features = ["derive"] } -serde_json = { version = "1.0" } -bytes = { version = "1.5" } +protobuf.workspace = true +tokio = { workspace = true, features = ["full", "rt-multi-thread", "tracing"] } +serde.workspace = true +serde_json.workspace = true +bytes.workspace = true crossbeam-utils = "0.8.15" lazy_static = "1.4.0" -parking_lot = "0.12.1" -tracing = { version = "0.1", features = ["log"] } +parking_lot.workspace = true +tracing.workspace = true # workspace lib-dispatch = { workspace = true } +#flowy-core = { workspace = true, features = ["profiling"] } flowy-core = { workspace = true } flowy-notification = { workspace = true } flowy-server = { workspace = true } -flowy-server-config = { workspace = true } +flowy-server-config = { workspace = true} collab-integrate = { workspace = true } flowy-derive = { path = "../../../shared-lib/flowy-derive" } diff --git a/frontend/rust-lib/dart-ffi/src/env_serde.rs b/frontend/rust-lib/dart-ffi/src/env_serde.rs index ac7f3ad820f2..276719323160 100644 --- a/frontend/rust-lib/dart-ffi/src/env_serde.rs +++ b/frontend/rust-lib/dart-ffi/src/env_serde.rs @@ -12,7 +12,7 @@ pub struct AppFlowyEnv { impl AppFlowyEnv { /// Parse the environment variable from the frontend application. The frontend will /// pass the environment variable as a json string after launching. - pub fn parser(env_str: &str) { + pub fn write_env_from(env_str: &str) { if let Ok(env) = serde_json::from_str::<AppFlowyEnv>(env_str) { env.supabase_config.write_env(); env.appflowy_cloud_config.write_env(); diff --git a/frontend/rust-lib/dart-ffi/src/lib.rs b/frontend/rust-lib/dart-ffi/src/lib.rs index 256ad1d8f216..3c488c6476b2 100644 --- a/frontend/rust-lib/dart-ffi/src/lib.rs +++ b/frontend/rust-lib/dart-ffi/src/lib.rs @@ -1,9 +1,11 @@ #![allow(clippy::not_unsafe_ptr_arg_deref)] +use std::sync::Arc; use std::{ffi::CStr, os::raw::c_char}; use lazy_static::lazy_static; -use parking_lot::RwLock; +use parking_lot::Mutex; +use tracing::{error, trace}; use flowy_core::*; use flowy_notification::{register_notification_sender, unregister_all_notification_sender}; @@ -25,9 +27,26 @@ mod protobuf; mod util; lazy_static! { - static ref APPFLOWY_CORE: RwLock<Option<AppFlowyCore>> = RwLock::new(None); + static ref APPFLOWY_CORE: MutexAppFlowyCore = MutexAppFlowyCore::new(); } +struct MutexAppFlowyCore(Arc<Mutex<Option<AppFlowyCore>>>); + +impl MutexAppFlowyCore { + fn new() -> Self { + Self(Arc::new(Mutex::new(None))) + } + + fn dispatcher(&self) -> Option<Arc<AFPluginDispatcher>> { + let binding = self.0.lock(); + let core = binding.as_ref(); + core.map(|core| core.event_dispatcher.clone()) + } +} + +unsafe impl Sync for MutexAppFlowyCore {} +unsafe impl Send for MutexAppFlowyCore {} + #[no_mangle] pub extern "C" fn init_sdk(path: *mut c_char) -> i64 { let c_str: &CStr = unsafe { CStr::from_ptr(path) }; @@ -36,32 +55,33 @@ pub extern "C" fn init_sdk(path: *mut c_char) -> i64 { let log_crates = vec!["flowy-ffi".to_string()]; let config = AppFlowyCoreConfig::new(path, DEFAULT_NAME.to_string()).log_filter("info", log_crates); - *APPFLOWY_CORE.write() = Some(AppFlowyCore::new(config)); + *APPFLOWY_CORE.0.lock() = Some(AppFlowyCore::new(config)); 0 } #[no_mangle] +#[allow(clippy::let_underscore_future)] pub extern "C" fn async_event(port: i64, input: *const u8, len: usize) { let request: AFPluginRequest = FFIRequest::from_u8_pointer(input, len).into(); - log::trace!( + trace!( "[FFI]: {} Async Event: {:?} with {} port", &request.id, &request.event, port ); - let dispatcher = match APPFLOWY_CORE.read().as_ref() { + let dispatcher = match APPFLOWY_CORE.dispatcher() { None => { - log::error!("sdk not init yet."); + error!("sdk not init yet."); return; }, - Some(e) => e.event_dispatcher.clone(), + Some(dispatcher) => dispatcher, }; - AFPluginDispatcher::async_send_with_callback( + AFPluginDispatcher::boxed_async_send_with_callback( dispatcher, request, move |resp: AFPluginEventResponse| { - log::trace!("[FFI]: Post data to dart through {} port", port); + trace!("[FFI]: Post data to dart through {} port", port); Box::pin(post_to_flutter(resp, port)) }, ); @@ -70,14 +90,14 @@ pub extern "C" fn async_event(port: i64, input: *const u8, len: usize) { #[no_mangle] pub extern "C" fn sync_event(input: *const u8, len: usize) -> *const u8 { let request: AFPluginRequest = FFIRequest::from_u8_pointer(input, len).into(); - log::trace!("[FFI]: {} Sync Event: {:?}", &request.id, &request.event,); + trace!("[FFI]: {} Sync Event: {:?}", &request.id, &request.event,); - let dispatcher = match APPFLOWY_CORE.read().as_ref() { + let dispatcher = match APPFLOWY_CORE.dispatcher() { None => { - log::error!("sdk not init yet."); + error!("sdk not init yet."); return forget_rust(Vec::default()); }, - Some(e) => e.event_dispatcher.clone(), + Some(dispatcher) => dispatcher, }; let _response = AFPluginDispatcher::sync_send(dispatcher, request); @@ -110,13 +130,13 @@ async fn post_to_flutter(response: AFPluginEventResponse, port: i64) { .await { Ok(_success) => { - log::trace!("[FFI]: Post data to dart success"); + trace!("[FFI]: Post data to dart success"); }, Err(e) => { if let Some(msg) = e.downcast_ref::<&str>() { - log::error!("[FFI]: {:?}", msg); + error!("[FFI]: {:?}", msg); } else { - log::error!("[FFI]: allo_isolate post panic"); + error!("[FFI]: allo_isolate post panic"); } }, } @@ -142,5 +162,5 @@ pub extern "C" fn backend_log(level: i64, data: *const c_char) { pub extern "C" fn set_env(data: *const c_char) { let c_str = unsafe { CStr::from_ptr(data) }; let serde_str = c_str.to_str().unwrap(); - AppFlowyEnv::parser(serde_str); + AppFlowyEnv::write_env_from(serde_str); } diff --git a/frontend/rust-lib/event-integration/Cargo.toml b/frontend/rust-lib/event-integration/Cargo.toml index 3c35b0ff82aa..82a342568c2e 100644 --- a/frontend/rust-lib/event-integration/Cargo.toml +++ b/frontend/rust-lib/event-integration/Cargo.toml @@ -21,20 +21,20 @@ lib-infra = { path = "../../../shared-lib/lib-infra" } flowy-server = { path = "../flowy-server" } flowy-server-config = { workspace = true } flowy-notification = { workspace = true } -anyhow = "1.0.71" +anyhow.workspace = true flowy-storage = { workspace = true } -serde = { version = "1.0", features = ["derive"] } -serde_json = {version = "1.0"} -protobuf = {version = "2.28.0"} -tokio = { version = "1.26", features = ["full"]} +serde.workspace = true +serde_json.workspace = true +protobuf.workspace = true +tokio = { workspace = true, features = ["full"]} futures-util = "0.3.26" thread-id = "3.3.0" -bytes = "1.4" +bytes.workspace = true nanoid = "0.4.0" -tracing = { version = "0.1.27" } -parking_lot = "0.12.1" -uuid = { version = "1.3.3", features = ["serde", "v4"] } +tracing.workspace = true +parking_lot.workspace = true +uuid.workspace = true collab = { version = "0.1.0" } collab-document = { version = "0.1.0" } collab-folder = { version = "0.1.0" } @@ -45,7 +45,7 @@ collab-entity = { version = "0.1.0" } [dev-dependencies] dotenv = "0.15.0" tempdir = "0.3.7" -uuid = { version = "1.3.3", features = ["v4"] } +uuid.workspace = true assert-json-diff = "2.0.2" tokio-postgres = { version = "0.7.8" } zip = "0.6.6" @@ -53,4 +53,5 @@ zip = "0.6.6" [features] default = ["supabase_cloud_test"] dart = ["flowy-core/dart"] -supabase_cloud_test = [] \ No newline at end of file +supabase_cloud_test = [] +single_thread = ["flowy-core/single_thread"] \ No newline at end of file diff --git a/frontend/rust-lib/event-integration/src/document/document_event.rs b/frontend/rust-lib/event-integration/src/document/document_event.rs index ab20d240bd39..4a2b782f9416 100644 --- a/frontend/rust-lib/event-integration/src/document/document_event.rs +++ b/frontend/rust-lib/event-integration/src/document/document_event.rs @@ -4,6 +4,10 @@ use serde_json::Value; use flowy_document2::entities::*; use flowy_document2::event_map::DocumentEvent; +use flowy_document2::parser::parser_entities::{ + ConvertDataToJsonPayloadPB, ConvertDataToJsonResponsePB, ConvertDocumentPayloadPB, + ConvertDocumentResponsePB, +}; use flowy_folder2::entities::{CreateViewPayloadPB, ViewLayoutPB, ViewPB}; use flowy_folder2::event_map::FolderEvent; @@ -34,7 +38,7 @@ impl DocumentEventTest { pub async fn create_document(&self) -> ViewPB { let core = &self.inner; - let current_workspace = core.get_current_workspace().await.workspace; + let current_workspace = core.get_current_workspace().await; let parent_id = current_workspace.id.clone(); let payload = CreateViewPayloadPB { @@ -108,6 +112,33 @@ impl DocumentEventTest { .await; } + pub async fn convert_document( + &self, + payload: ConvertDocumentPayloadPB, + ) -> ConvertDocumentResponsePB { + let core = &self.inner; + EventBuilder::new(core.clone()) + .event(DocumentEvent::ConvertDocument) + .payload(payload) + .async_send() + .await + .parse::<ConvertDocumentResponsePB>() + } + + // convert data to json for document event test + pub async fn convert_data_to_json( + &self, + payload: ConvertDataToJsonPayloadPB, + ) -> ConvertDataToJsonResponsePB { + let core = &self.inner; + EventBuilder::new(core.clone()) + .event(DocumentEvent::ConvertDataToJSON) + .payload(payload) + .async_send() + .await + .parse::<ConvertDataToJsonResponsePB>() + } + pub async fn create_text(&self, payload: TextDeltaPayloadPB) { let core = &self.inner; EventBuilder::new(core.clone()) diff --git a/frontend/rust-lib/event-integration/src/event_builder.rs b/frontend/rust-lib/event-integration/src/event_builder.rs index a6c0209622f2..afa4590add7f 100644 --- a/frontend/rust-lib/event-integration/src/event_builder.rs +++ b/frontend/rust-lib/event-integration/src/event_builder.rs @@ -48,13 +48,6 @@ impl EventBuilder { self } - pub fn sync_send(mut self) -> Self { - let request = self.get_request(); - let resp = AFPluginDispatcher::sync_send(self.dispatch(), request); - self.context.response = Some(resp); - self - } - pub async fn async_send(mut self) -> Self { let request = self.get_request(); let resp = AFPluginDispatcher::async_send(self.dispatch(), request).await; diff --git a/frontend/rust-lib/event-integration/src/folder_event.rs b/frontend/rust-lib/event-integration/src/folder_event.rs index 5061cf3ce5e4..df06634bd898 100644 --- a/frontend/rust-lib/event-integration/src/folder_event.rs +++ b/frontend/rust-lib/event-integration/src/folder_event.rs @@ -47,12 +47,12 @@ impl EventIntegrationTest { .items } - pub async fn get_current_workspace(&self) -> WorkspaceSettingPB { + pub async fn get_current_workspace(&self) -> WorkspacePB { EventBuilder::new(self.clone()) - .event(FolderEvent::GetCurrentWorkspace) + .event(FolderEvent::ReadCurrentWorkspace) .async_send() .await - .parse::<WorkspaceSettingPB>() + .parse::<WorkspacePB>() } pub async fn get_all_workspace_views(&self) -> Vec<ViewPB> { @@ -144,19 +144,10 @@ pub struct ViewTest { pub workspace: WorkspacePB, pub child_view: ViewPB, } - impl ViewTest { #[allow(dead_code)] pub async fn new(sdk: &EventIntegrationTest, layout: ViewLayoutPB, data: Vec<u8>) -> Self { - let workspace = create_workspace(sdk, "Workspace", "").await; - let payload = WorkspaceIdPB { - value: Some(workspace.id.clone()), - }; - let _ = EventBuilder::new(sdk.clone()) - .event(OpenWorkspace) - .payload(payload) - .async_send() - .await; + let workspace = sdk.folder_manager.get_current_workspace().await.unwrap(); let payload = CreateViewPayloadPB { parent_view_id: workspace.id.clone(), @@ -196,6 +187,7 @@ impl ViewTest { } } +#[allow(dead_code)] async fn create_workspace(sdk: &EventIntegrationTest, name: &str, desc: &str) -> WorkspacePB { let request = CreateWorkspacePayloadPB { name: name.to_owned(), diff --git a/frontend/rust-lib/event-integration/src/lib.rs b/frontend/rust-lib/event-integration/src/lib.rs index f99304eba93e..1a9d9cb7a14e 100644 --- a/frontend/rust-lib/event-integration/src/lib.rs +++ b/frontend/rust-lib/event-integration/src/lib.rs @@ -27,30 +27,23 @@ pub struct EventIntegrationTest { pub notification_sender: TestNotificationSender, } -impl Default for EventIntegrationTest { - fn default() -> Self { +impl EventIntegrationTest { + pub async fn new() -> Self { let temp_dir = temp_dir().join(nanoid!(6)); std::fs::create_dir_all(&temp_dir).unwrap(); - Self::new_with_user_data_path(temp_dir, nanoid!(6)) - } -} - -impl EventIntegrationTest { - pub fn new() -> Self { - Self::default() + Self::new_with_user_data_path(temp_dir, nanoid!(6)).await } - pub fn new_with_user_data_path(path: PathBuf, name: String) -> Self { + pub async fn new_with_user_data_path(path: PathBuf, name: String) -> Self { let config = AppFlowyCoreConfig::new(path.to_str().unwrap(), name).log_filter( "trace", vec![ "flowy_test".to_string(), - // "lib_dispatch".to_string() + "tokio".to_string(), + "lib_dispatch".to_string(), ], ); - let inner = std::thread::spawn(|| AppFlowyCore::new(config)) - .join() - .unwrap(); + let inner = init_core(config).await; let notification_sender = TestNotificationSender::new(); let auth_type = Arc::new(RwLock::new(AuthTypePB::Local)); register_notification_sender(notification_sender.clone()); @@ -64,6 +57,21 @@ impl EventIntegrationTest { } } +#[cfg(feature = "single_thread")] +async fn init_core(config: AppFlowyCoreConfig) -> AppFlowyCore { + // let runtime = tokio::runtime::Runtime::new().unwrap(); + // let local_set = tokio::task::LocalSet::new(); + // runtime.block_on(AppFlowyCore::new(config)) + AppFlowyCore::new(config).await +} + +#[cfg(not(feature = "single_thread"))] +async fn init_core(config: AppFlowyCoreConfig) -> AppFlowyCore { + std::thread::spawn(|| AppFlowyCore::new(config)) + .join() + .unwrap() +} + impl std::ops::Deref for EventIntegrationTest { type Target = AppFlowyCore; diff --git a/frontend/rust-lib/event-integration/src/user_event.rs b/frontend/rust-lib/event-integration/src/user_event.rs index 9d03b8f1e8f4..8a180c76e3a0 100644 --- a/frontend/rust-lib/event-integration/src/user_event.rs +++ b/frontend/rust-lib/event-integration/src/user_event.rs @@ -6,6 +6,7 @@ use bytes::Bytes; use nanoid::nanoid; use protobuf::ProtobufError; use tokio::sync::broadcast::{channel, Sender}; +use tracing::error; use uuid::Uuid; use flowy_notification::entities::SubscribeObject; @@ -17,7 +18,7 @@ use flowy_user::entities::{ }; use flowy_user::errors::{FlowyError, FlowyResult}; use flowy_user::event_map::UserEvent::*; -use lib_dispatch::prelude::{AFPluginDispatcher, AFPluginRequest, ToBytes}; +use lib_dispatch::prelude::{af_spawn, AFPluginDispatcher, AFPluginRequest, ToBytes}; use crate::event_builder::EventBuilder; use crate::EventIntegrationTest; @@ -44,7 +45,7 @@ impl EventIntegrationTest { } pub async fn new_with_guest_user() -> Self { - let test = Self::default(); + let test = Self::new().await; test.sign_up_as_guest().await; test } @@ -213,7 +214,7 @@ impl TestNotificationSender { let (tx, rx) = tokio::sync::mpsc::channel::<T>(10); let mut receiver = self.sender.subscribe(); let ty = ty.into(); - tokio::spawn(async move { + af_spawn(async move { // DatabaseNotification::DidUpdateDatabaseSnapshotState while let Ok(value) = receiver.recv().await { if value.id == id && value.ty == ty { @@ -245,7 +246,7 @@ impl TestNotificationSender { let id = id.to_string(); let (tx, rx) = tokio::sync::mpsc::channel::<T>(10); let mut receiver = self.sender.subscribe(); - tokio::spawn(async move { + af_spawn(async move { while let Ok(value) = receiver.recv().await { if value.id == id { if let Some(payload) = value.payload { @@ -263,7 +264,9 @@ impl TestNotificationSender { } impl NotificationSender for TestNotificationSender { fn send_subject(&self, subject: SubscribeObject) -> Result<(), String> { - let _ = self.sender.send(subject); + if let Err(err) = self.sender.send(subject) { + error!("Failed to send notification: {:?}", err); + } Ok(()) } } diff --git a/frontend/rust-lib/event-integration/tests/database/local_test/group_test.rs b/frontend/rust-lib/event-integration/tests/database/local_test/group_test.rs new file mode 100644 index 000000000000..753c4d5edeb0 --- /dev/null +++ b/frontend/rust-lib/event-integration/tests/database/local_test/group_test.rs @@ -0,0 +1,31 @@ +use event_integration::EventIntegrationTest; + +#[tokio::test] +async fn update_group_name_test() { + let test = EventIntegrationTest::new_with_guest_user().await; + let current_workspace = test.get_current_workspace().await; + let board_view = test + .create_board(¤t_workspace.id, "my board view".to_owned(), vec![]) + .await; + + let groups = test.get_groups(&board_view.id).await; + assert_eq!(groups.len(), 4); + assert_eq!(groups[1].group_name, "To Do"); + assert_eq!(groups[2].group_name, "Doing"); + assert_eq!(groups[3].group_name, "Done"); + + test + .update_group( + &board_view.id, + &groups[1].group_id, + &groups[1].field_id, + Some("To Do?".to_string()), + None, + ) + .await; + + let groups = test.get_groups(&board_view.id).await; + assert_eq!(groups.len(), 4); + assert_eq!(groups[1].group_name, "To Do?"); + assert_eq!(groups[2].group_name, "Doing"); +} diff --git a/frontend/rust-lib/event-integration/tests/database/local_test/mod.rs b/frontend/rust-lib/event-integration/tests/database/local_test/mod.rs index 585722915df8..8b91f8511371 100644 --- a/frontend/rust-lib/event-integration/tests/database/local_test/mod.rs +++ b/frontend/rust-lib/event-integration/tests/database/local_test/mod.rs @@ -1 +1,2 @@ +mod group_test; mod test; diff --git a/frontend/rust-lib/event-integration/tests/database/local_test/test.rs b/frontend/rust-lib/event-integration/tests/database/local_test/test.rs index 3b3cdd171e32..ac247b0a1846 100644 --- a/frontend/rust-lib/event-integration/tests/database/local_test/test.rs +++ b/frontend/rust-lib/event-integration/tests/database/local_test/test.rs @@ -14,7 +14,7 @@ use lib_infra::util::timestamp; #[tokio::test] async fn get_database_id_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -36,7 +36,7 @@ async fn get_database_id_event_test() { #[tokio::test] async fn get_database_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -50,7 +50,7 @@ async fn get_database_event_test() { #[tokio::test] async fn get_field_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -65,7 +65,7 @@ async fn get_field_event_test() { #[tokio::test] async fn create_field_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -79,7 +79,7 @@ async fn create_field_event_test() { #[tokio::test] async fn delete_field_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -100,7 +100,7 @@ async fn delete_field_event_test() { #[tokio::test] async fn delete_primary_field_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -115,7 +115,7 @@ async fn delete_primary_field_event_test() { #[tokio::test] async fn update_field_type_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -133,7 +133,7 @@ async fn update_field_type_event_test() { #[tokio::test] async fn update_primary_field_type_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -152,7 +152,7 @@ async fn update_primary_field_type_event_test() { #[tokio::test] async fn duplicate_field_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -170,7 +170,7 @@ async fn duplicate_field_event_test() { #[tokio::test] async fn duplicate_primary_field_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -184,7 +184,7 @@ async fn duplicate_primary_field_test() { #[tokio::test] async fn get_primary_field_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -197,7 +197,7 @@ async fn get_primary_field_event_test() { #[tokio::test] async fn create_row_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -210,7 +210,7 @@ async fn create_row_event_test() { #[tokio::test] async fn delete_row_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -233,7 +233,7 @@ async fn delete_row_event_test() { #[tokio::test] async fn get_row_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -249,7 +249,7 @@ async fn get_row_event_test() { #[tokio::test] async fn update_row_meta_event_with_url_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -265,6 +265,7 @@ async fn update_row_meta_event_with_url_test() { view_id: grid_view.id.clone(), icon_url: Some("icon_url".to_owned()), cover_url: None, + is_document_empty: None, }; let error = test.update_row_meta(changeset).await; assert!(error.is_none()); @@ -277,7 +278,7 @@ async fn update_row_meta_event_with_url_test() { #[tokio::test] async fn update_row_meta_event_with_cover_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -293,6 +294,7 @@ async fn update_row_meta_event_with_cover_test() { view_id: grid_view.id.clone(), cover_url: Some("cover url".to_owned()), icon_url: None, + is_document_empty: None, }; let error = test.update_row_meta(changeset).await; assert!(error.is_none()); @@ -305,7 +307,7 @@ async fn update_row_meta_event_with_cover_test() { #[tokio::test] async fn delete_row_event_with_invalid_row_id_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -318,7 +320,7 @@ async fn delete_row_event_with_invalid_row_id_test() { #[tokio::test] async fn duplicate_row_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -335,7 +337,7 @@ async fn duplicate_row_event_test() { #[tokio::test] async fn duplicate_row_event_with_invalid_row_id_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -352,7 +354,7 @@ async fn duplicate_row_event_with_invalid_row_id_test() { #[tokio::test] async fn move_row_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -372,7 +374,7 @@ async fn move_row_event_test() { #[tokio::test] async fn move_row_event_test2() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -392,7 +394,7 @@ async fn move_row_event_test2() { #[tokio::test] async fn move_row_event_with_invalid_row_id_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -419,7 +421,7 @@ async fn move_row_event_with_invalid_row_id_test() { #[tokio::test] async fn update_text_cell_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -449,7 +451,7 @@ async fn update_text_cell_event_test() { #[tokio::test] async fn update_checkbox_cell_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -480,7 +482,7 @@ async fn update_checkbox_cell_event_test() { #[tokio::test] async fn update_single_select_cell_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -507,7 +509,7 @@ async fn update_single_select_cell_event_test() { #[tokio::test] async fn update_date_cell_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -544,7 +546,7 @@ async fn update_date_cell_event_test() { #[tokio::test] async fn update_date_cell_event_with_empty_time_str_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -580,7 +582,7 @@ async fn update_date_cell_event_with_empty_time_str_test() { #[tokio::test] async fn create_checklist_field_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -601,7 +603,7 @@ async fn create_checklist_field_test() { #[tokio::test] async fn update_checklist_cell_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -658,7 +660,7 @@ async fn update_checklist_cell_test() { #[tokio::test] async fn get_groups_event_with_grid_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my board view".to_owned(), vec![]) .await; @@ -670,7 +672,7 @@ async fn get_groups_event_with_grid_test() { #[tokio::test] async fn get_groups_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let board_view = test .create_board(¤t_workspace.id, "my board view".to_owned(), vec![]) .await; @@ -682,7 +684,7 @@ async fn get_groups_event_test() { #[tokio::test] async fn move_group_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let board_view = test .create_board(¤t_workspace.id, "my board view".to_owned(), vec![]) .await; @@ -716,7 +718,7 @@ async fn move_group_event_test() { #[tokio::test] async fn move_group_event_with_invalid_id_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let board_view = test .create_board(¤t_workspace.id, "my board view".to_owned(), vec![]) .await; @@ -738,7 +740,7 @@ async fn move_group_event_with_invalid_id_test() { #[tokio::test] async fn rename_group_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let board_view = test .create_board(¤t_workspace.id, "my board view".to_owned(), vec![]) .await; @@ -761,9 +763,9 @@ async fn rename_group_event_test() { } #[tokio::test] -async fn hide_group_event_test2() { +async fn hide_group_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let board_view = test .create_board(¤t_workspace.id, "my board view".to_owned(), vec![]) .await; @@ -784,14 +786,15 @@ async fn hide_group_event_test2() { assert!(error.is_none()); let groups = test.get_groups(&board_view.id).await; - assert_eq!(groups.len(), 3); + assert_eq!(groups.len(), 4); + assert_eq!(groups[0].is_visible, false); } // Update the database layout type from grid to board #[tokio::test] async fn update_database_layout_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -813,7 +816,7 @@ async fn update_database_layout_event_test() { #[tokio::test] async fn update_database_layout_event_test2() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let grid_view = test .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; @@ -845,7 +848,7 @@ async fn update_database_layout_event_test2() { #[tokio::test] async fn set_group_by_checkbox_field_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let board_view = test .create_board(¤t_workspace.id, "my board view".to_owned(), vec![]) .await; @@ -862,7 +865,7 @@ async fn set_group_by_checkbox_field_test() { #[tokio::test] async fn get_all_calendar_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let calendar_view = test .create_calendar(¤t_workspace.id, "my calendar view".to_owned(), vec![]) .await; @@ -875,7 +878,7 @@ async fn get_all_calendar_event_test() { #[tokio::test] async fn create_calendar_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let calendar_view = test .create_calendar(¤t_workspace.id, "my calendar view".to_owned(), vec![]) .await; diff --git a/frontend/rust-lib/event-integration/tests/database/supabase_test/helper.rs b/frontend/rust-lib/event-integration/tests/database/supabase_test/helper.rs index c146644da2c8..19d5d615b7df 100644 --- a/frontend/rust-lib/event-integration/tests/database/supabase_test/helper.rs +++ b/frontend/rust-lib/event-integration/tests/database/supabase_test/helper.rs @@ -22,13 +22,13 @@ pub struct FlowySupabaseDatabaseTest { impl FlowySupabaseDatabaseTest { #[allow(dead_code)] pub async fn new_with_user(uuid: String) -> Option<Self> { - let inner = FlowySupabaseTest::new()?; + let inner = FlowySupabaseTest::new().await?; inner.supabase_sign_up_with_uuid(&uuid, None).await.unwrap(); Some(Self { uuid, inner }) } pub async fn new_with_new_user() -> Option<Self> { - let inner = FlowySupabaseTest::new()?; + let inner = FlowySupabaseTest::new().await?; let uuid = uuid::Uuid::new_v4().to_string(); let _ = inner.supabase_sign_up_with_uuid(&uuid, None).await.unwrap(); Some(Self { uuid, inner }) @@ -38,11 +38,7 @@ impl FlowySupabaseDatabaseTest { let current_workspace = self.inner.get_current_workspace().await; let view = self .inner - .create_grid( - ¤t_workspace.workspace.id, - "my database".to_string(), - vec![], - ) + .create_grid(¤t_workspace.id, "my database".to_string(), vec![]) .await; let database = self.inner.get_database(&view.id).await; (view, database) diff --git a/frontend/rust-lib/event-integration/tests/database/supabase_test/test.rs b/frontend/rust-lib/event-integration/tests/database/supabase_test/test.rs index f5867c34f7f2..6877e511c228 100644 --- a/frontend/rust-lib/event-integration/tests/database/supabase_test/test.rs +++ b/frontend/rust-lib/event-integration/tests/database/supabase_test/test.rs @@ -14,11 +14,11 @@ use crate::util::receive_with_timeout; async fn supabase_initial_database_snapshot_test() { if let Some(test) = FlowySupabaseDatabaseTest::new_with_new_user().await { let (view, database) = test.create_database().await; - let mut rx = test + let rx = test .notification_sender .subscribe::<DatabaseSnapshotStatePB>(&database.id, DidUpdateDatabaseSnapshotState); - receive_with_timeout(&mut rx, Duration::from_secs(30)) + receive_with_timeout(rx, Duration::from_secs(30)) .await .unwrap(); @@ -51,10 +51,10 @@ async fn supabase_edit_database_test() { .await; // wait all updates are send to the remote - let mut rx = test + let rx = test .notification_sender .subscribe_with_condition::<DatabaseSyncStatePB, _>(&database.id, |pb| pb.is_finish); - receive_with_timeout(&mut rx, Duration::from_secs(30)) + receive_with_timeout(rx, Duration::from_secs(30)) .await .unwrap(); diff --git a/frontend/rust-lib/event-integration/tests/document/af_cloud_test/edit_test.rs b/frontend/rust-lib/event-integration/tests/document/af_cloud_test/edit_test.rs index d261cc26f07b..2c78a3c5ab53 100644 --- a/frontend/rust-lib/event-integration/tests/document/af_cloud_test/edit_test.rs +++ b/frontend/rust-lib/event-integration/tests/document/af_cloud_test/edit_test.rs @@ -12,17 +12,17 @@ async fn af_cloud_edit_document_test() { let document_id = test.create_document().await; let cloned_test = test.clone(); let cloned_document_id = document_id.clone(); - tokio::spawn(async move { + test.inner.dispatcher().spawn(async move { cloned_test .insert_document_text(&cloned_document_id, "hello world", 0) .await; }); // wait all update are send to the remote - let mut rx = test + let rx = test .notification_sender .subscribe_with_condition::<DocumentSyncStatePB, _>(&document_id, |pb| pb.is_finish); - receive_with_timeout(&mut rx, Duration::from_secs(15)) + receive_with_timeout(rx, Duration::from_secs(25)) .await .unwrap(); diff --git a/frontend/rust-lib/event-integration/tests/document/af_cloud_test/util.rs b/frontend/rust-lib/event-integration/tests/document/af_cloud_test/util.rs index 4abf56fa070c..c39b36d481ea 100644 --- a/frontend/rust-lib/event-integration/tests/document/af_cloud_test/util.rs +++ b/frontend/rust-lib/event-integration/tests/document/af_cloud_test/util.rs @@ -8,7 +8,7 @@ pub struct AFCloudDocumentTest { impl AFCloudDocumentTest { pub async fn new() -> Option<Self> { - let inner = AFCloudTest::new()?; + let inner = AFCloudTest::new().await?; let email = generate_test_email(); let _ = inner.af_cloud_sign_in_with_email(&email).await.unwrap(); Some(Self { inner }) @@ -18,11 +18,7 @@ impl AFCloudDocumentTest { let current_workspace = self.inner.get_current_workspace().await; let view = self .inner - .create_document( - ¤t_workspace.workspace.id, - "my document".to_string(), - vec![], - ) + .create_document(¤t_workspace.id, "my document".to_string(), vec![]) .await; view.id } diff --git a/frontend/rust-lib/event-integration/tests/document/local_test/test.rs b/frontend/rust-lib/event-integration/tests/document/local_test/test.rs index 0016a9118cd4..86cea382590b 100644 --- a/frontend/rust-lib/event-integration/tests/document/local_test/test.rs +++ b/frontend/rust-lib/event-integration/tests/document/local_test/test.rs @@ -2,6 +2,9 @@ use collab_document::blocks::json_str_to_hashmap; use event_integration::document::document_event::DocumentEventTest; use event_integration::document::utils::*; use flowy_document2::entities::*; +use flowy_document2::parser::parser_entities::{ + ConvertDataToJsonPayloadPB, ConvertDocumentPayloadPB, InputType, NestedBlock, ParseTypePB, +}; use serde_json::{json, Value}; use std::collections::HashMap; @@ -120,3 +123,85 @@ async fn apply_text_delta_test() { json!([{ "insert": "Hello! World" }]).to_string() ); } + +macro_rules! generate_convert_document_test_cases { + ($($json:ident, $text:ident, $html:ident),*) => { + [ + $((ParseTypePB { json: $json, text: $text, html: $html }, ($json, $text, $html))),* + ] + }; +} + +#[tokio::test] +async fn convert_document_test() { + let test = DocumentEventTest::new().await; + let view = test.create_document().await; + + let test_cases = generate_convert_document_test_cases! { + true, true, true, + false, true, true, + false, false, false + }; + + for (export_types, (json_assert, text_assert, html_assert)) in test_cases.iter() { + let copy_payload = ConvertDocumentPayloadPB { + document_id: view.id.to_string(), + range: None, + parse_types: export_types.clone(), + }; + let result = test.convert_document(copy_payload).await; + assert_eq!(result.json.is_some(), *json_assert); + assert_eq!(result.text.is_some(), *text_assert); + assert_eq!(result.html.is_some(), *html_assert); + } +} + +/// test convert data to json +/// - input html: <p>Hello</p><p> World!</p> +/// - input plain text: Hello World! +/// - output json: { "type": "page", "data": {}, "children": [{ "type": "paragraph", "children": [], "data": { "delta": [{ "insert": "Hello" }] } }, { "type": "paragraph", "children": [], "data": { "delta": [{ "insert": " World!" }] } }] } +#[tokio::test] +async fn convert_data_to_json_test() { + let test = DocumentEventTest::new().await; + let _ = test.create_document().await; + + let html = r#"<p>Hello</p><p>World!</p>"#; + let payload = ConvertDataToJsonPayloadPB { + data: html.to_string(), + input_type: InputType::Html, + }; + let result = test.convert_data_to_json(payload).await; + let expect_json = json!({ + "type": "page", + "data": {}, + "children": [{ + "type": "paragraph", + "children": [], + "data": { + "delta": [{ "insert": "Hello" }] + } + }, { + "type": "paragraph", + "children": [], + "data": { + "delta": [{ "insert": "World!" }] + } + }] + }); + + let expect_json = serde_json::from_value::<NestedBlock>(expect_json).unwrap(); + assert!(serde_json::from_str::<NestedBlock>(&result.json) + .unwrap() + .eq(&expect_json)); + + let plain_text = "Hello\nWorld!"; + let payload = ConvertDataToJsonPayloadPB { + data: plain_text.to_string(), + input_type: InputType::PlainText, + }; + let result = test.convert_data_to_json(payload).await; + + assert!(serde_json::from_str::<NestedBlock>(&result.json) + .unwrap() + .eq(&expect_json)); +} diff --git a/frontend/rust-lib/event-integration/tests/document/supabase_test/edit_test.rs b/frontend/rust-lib/event-integration/tests/document/supabase_test/edit_test.rs index a4ad36d350f2..01f3ed5c215b 100644 --- a/frontend/rust-lib/event-integration/tests/document/supabase_test/edit_test.rs +++ b/frontend/rust-lib/event-integration/tests/document/supabase_test/edit_test.rs @@ -14,17 +14,17 @@ async fn supabase_document_edit_sync_test() { let cloned_test = test.clone(); let cloned_document_id = document_id.clone(); - tokio::spawn(async move { + test.inner.dispatcher().spawn(async move { cloned_test .insert_document_text(&cloned_document_id, "hello world", 0) .await; }); // wait all update are send to the remote - let mut rx = test + let rx = test .notification_sender .subscribe_with_condition::<DocumentSyncStatePB, _>(&document_id, |pb| pb.is_finish); - receive_with_timeout(&mut rx, Duration::from_secs(30)) + receive_with_timeout(rx, Duration::from_secs(30)) .await .unwrap(); @@ -47,10 +47,10 @@ async fn supabase_document_edit_sync_test2() { } // wait all update are send to the remote - let mut rx = test + let rx = test .notification_sender .subscribe_with_condition::<DocumentSyncStatePB, _>(&document_id, |pb| pb.is_finish); - receive_with_timeout(&mut rx, Duration::from_secs(30)) + receive_with_timeout(rx, Duration::from_secs(30)) .await .unwrap(); diff --git a/frontend/rust-lib/event-integration/tests/document/supabase_test/file_test.rs b/frontend/rust-lib/event-integration/tests/document/supabase_test/file_test.rs index 02c0e870c78c..5c4b9c860955 100644 --- a/frontend/rust-lib/event-integration/tests/document/supabase_test/file_test.rs +++ b/frontend/rust-lib/event-integration/tests/document/supabase_test/file_test.rs @@ -12,7 +12,7 @@ use crate::document::supabase_test::helper::FlowySupabaseDocumentTest; #[tokio::test] async fn supabase_document_upload_text_file_test() { if let Some(test) = FlowySupabaseDocumentTest::new().await { - let workspace_id = test.get_current_workspace().await.workspace.id; + let workspace_id = test.get_current_workspace().await.id; let storage_service = test .document_manager .get_file_storage_service() @@ -43,7 +43,7 @@ async fn supabase_document_upload_text_file_test() { #[tokio::test] async fn supabase_document_upload_zip_file_test() { if let Some(test) = FlowySupabaseDocumentTest::new().await { - let workspace_id = test.get_current_workspace().await.workspace.id; + let workspace_id = test.get_current_workspace().await.id; let storage_service = test .document_manager .get_file_storage_service() @@ -85,7 +85,7 @@ async fn supabase_document_upload_zip_file_test() { #[tokio::test] async fn supabase_document_upload_image_test() { if let Some(test) = FlowySupabaseDocumentTest::new().await { - let workspace_id = test.get_current_workspace().await.workspace.id; + let workspace_id = test.get_current_workspace().await.id; let storage_service = test .document_manager .get_file_storage_service() diff --git a/frontend/rust-lib/event-integration/tests/document/supabase_test/helper.rs b/frontend/rust-lib/event-integration/tests/document/supabase_test/helper.rs index 7244a33a34f4..cc3344bd4428 100644 --- a/frontend/rust-lib/event-integration/tests/document/supabase_test/helper.rs +++ b/frontend/rust-lib/event-integration/tests/document/supabase_test/helper.rs @@ -13,7 +13,7 @@ pub struct FlowySupabaseDocumentTest { impl FlowySupabaseDocumentTest { pub async fn new() -> Option<Self> { - let inner = FlowySupabaseTest::new()?; + let inner = FlowySupabaseTest::new().await?; let uuid = uuid::Uuid::new_v4().to_string(); let _ = inner.supabase_sign_up_with_uuid(&uuid, None).await; Some(Self { inner }) @@ -23,11 +23,7 @@ impl FlowySupabaseDocumentTest { let current_workspace = self.inner.get_current_workspace().await; self .inner - .create_document( - ¤t_workspace.workspace.id, - "my document".to_string(), - vec![], - ) + .create_document(¤t_workspace.id, "my document".to_string(), vec![]) .await } diff --git a/frontend/rust-lib/event-integration/tests/folder/local_test/folder_test.rs b/frontend/rust-lib/event-integration/tests/folder/local_test/folder_test.rs index 2cb4c93fe958..9323e36a3d78 100644 --- a/frontend/rust-lib/event-integration/tests/folder/local_test/folder_test.rs +++ b/frontend/rust-lib/event-integration/tests/folder/local_test/folder_test.rs @@ -1,53 +1,10 @@ -use collab_folder::core::ViewLayout; +use collab_folder::ViewLayout; use flowy_folder2::entities::icon::{ViewIconPB, ViewIconTypePB}; use crate::folder::local_test::script::FolderScript::*; use crate::folder::local_test::script::FolderTest; -#[tokio::test] -async fn read_all_workspace_test() { - let mut test = FolderTest::new().await; - test.run_scripts(vec![ReadAllWorkspaces]).await; - assert!(!test.all_workspace.is_empty()); -} - -#[tokio::test] -async fn create_workspace_test() { - let mut test = FolderTest::new().await; - let name = "My new workspace".to_owned(); - let desc = "Daily routines".to_owned(); - test - .run_scripts(vec![CreateWorkspace { - name: name.clone(), - desc: desc.clone(), - }]) - .await; - - let workspace = test.workspace.clone(); - assert_eq!(workspace.name, name); - - test - .run_scripts(vec![ - ReadWorkspace(Some(workspace.id.clone())), - AssertWorkspace(workspace), - ]) - .await; -} - -#[tokio::test] -async fn get_workspace_test() { - let mut test = FolderTest::new().await; - let workspace = test.workspace.clone(); - - test - .run_scripts(vec![ - ReadWorkspace(Some(workspace.id.clone())), - AssertWorkspace(workspace), - ]) - .await; -} - #[tokio::test] async fn create_parent_view_test() { let mut test = FolderTest::new().await; diff --git a/frontend/rust-lib/event-integration/tests/folder/local_test/script.rs b/frontend/rust-lib/event-integration/tests/folder/local_test/script.rs index 09a11a94f865..bf1c00e8833f 100644 --- a/frontend/rust-lib/event-integration/tests/folder/local_test/script.rs +++ b/frontend/rust-lib/event-integration/tests/folder/local_test/script.rs @@ -1,4 +1,4 @@ -use collab_folder::core::ViewLayout; +use collab_folder::ViewLayout; use event_integration::event_builder::EventBuilder; use event_integration::EventIntegrationTest; @@ -7,14 +7,15 @@ use flowy_folder2::entities::*; use flowy_folder2::event_map::FolderEvent::*; pub enum FolderScript { - // Workspace - ReadAllWorkspaces, + #[allow(dead_code)] CreateWorkspace { name: String, desc: String, }, + #[allow(dead_code)] AssertWorkspace(WorkspacePB), - ReadWorkspace(Option<String>), + #[allow(dead_code)] + ReadWorkspace(String), // App CreateParentView { @@ -65,7 +66,6 @@ pub enum FolderScript { pub struct FolderTest { pub sdk: EventIntegrationTest, - pub all_workspace: Vec<WorkspacePB>, pub workspace: WorkspacePB, pub parent_view: ViewPB, pub child_view: ViewPB, @@ -75,10 +75,17 @@ pub struct FolderTest { impl FolderTest { pub async fn new() -> Self { - let sdk = EventIntegrationTest::new(); + let sdk = EventIntegrationTest::new().await; let _ = sdk.init_anon_user().await; - let workspace = create_workspace(&sdk, "FolderWorkspace", "Folder test workspace").await; - let parent_view = create_app(&sdk, &workspace.id, "Folder App", "Folder test app").await; + let workspace = sdk.folder_manager.get_current_workspace().await.unwrap(); + let parent_view = create_view( + &sdk, + &workspace.id, + "Folder App", + "Folder test app", + ViewLayout::Document, + ) + .await; let view = create_view( &sdk, &parent_view.id, @@ -89,7 +96,6 @@ impl FolderTest { .await; Self { sdk, - all_workspace: vec![], workspace, parent_view, child_view: view, @@ -107,10 +113,6 @@ impl FolderTest { pub async fn run_script(&mut self, script: FolderScript) { let sdk = &self.sdk; match script { - FolderScript::ReadAllWorkspaces => { - let all_workspace = read_workspace(sdk, None).await; - self.all_workspace = all_workspace; - }, FolderScript::CreateWorkspace { name, desc } => { let workspace = create_workspace(sdk, &name, &desc).await; self.workspace = workspace; @@ -119,11 +121,11 @@ impl FolderTest { assert_eq!(self.workspace, workspace, "Workspace not equal"); }, FolderScript::ReadWorkspace(workspace_id) => { - let workspace = read_workspace(sdk, workspace_id).await.pop().unwrap(); + let workspace = read_workspace(sdk, workspace_id).await; self.workspace = workspace; }, FolderScript::CreateParentView { name, desc } => { - let app = create_app(sdk, &self.workspace.id, &name, &desc).await; + let app = create_view(sdk, &self.workspace.id, &name, &desc, ViewLayout::Document).await; self.parent_view = app; }, FolderScript::AssertParentView(app) => { @@ -215,70 +217,27 @@ pub async fn create_workspace(sdk: &EventIntegrationTest, name: &str, desc: &str .parse::<WorkspacePB>() } -pub async fn read_workspace( - sdk: &EventIntegrationTest, - workspace_id: Option<String>, -) -> Vec<WorkspacePB> { +pub async fn read_workspace(sdk: &EventIntegrationTest, workspace_id: String) -> WorkspacePB { let request = WorkspaceIdPB { value: workspace_id, }; - let repeated_workspace = EventBuilder::new(sdk.clone()) - .event(ReadAllWorkspaces) - .payload(request.clone()) - .async_send() - .await - .parse::<RepeatedWorkspacePB>(); - - let workspaces; - if let Some(workspace_id) = &request.value { - workspaces = repeated_workspace - .items - .into_iter() - .filter(|workspace| &workspace.id == workspace_id) - .collect::<Vec<WorkspacePB>>(); - debug_assert_eq!(workspaces.len(), 1); - } else { - workspaces = repeated_workspace.items; - } - - workspaces -} - -pub async fn create_app( - sdk: &EventIntegrationTest, - workspace_id: &str, - name: &str, - desc: &str, -) -> ViewPB { - let create_view_request = CreateViewPayloadPB { - parent_view_id: workspace_id.to_owned(), - name: name.to_string(), - desc: desc.to_string(), - thumbnail: None, - layout: ViewLayout::Document.into(), - initial_data: vec![], - meta: Default::default(), - set_as_current: true, - index: None, - }; - EventBuilder::new(sdk.clone()) - .event(CreateView) - .payload(create_view_request) + .event(ReadCurrentWorkspace) + .payload(request.clone()) .async_send() .await - .parse::<ViewPB>() + .parse::<WorkspacePB>() } pub async fn create_view( sdk: &EventIntegrationTest, - app_id: &str, + parent_view_id: &str, name: &str, desc: &str, layout: ViewLayout, ) -> ViewPB { let request = CreateViewPayloadPB { - parent_view_id: app_id.to_string(), + parent_view_id: parent_view_id.to_string(), name: name.to_string(), desc: desc.to_string(), thumbnail: None, diff --git a/frontend/rust-lib/event-integration/tests/folder/local_test/subscription_test.rs b/frontend/rust-lib/event-integration/tests/folder/local_test/subscription_test.rs index 2df246b212ac..6162095014fa 100644 --- a/frontend/rust-lib/event-integration/tests/folder/local_test/subscription_test.rs +++ b/frontend/rust-lib/event-integration/tests/folder/local_test/subscription_test.rs @@ -17,20 +17,20 @@ use crate::util::receive_with_timeout; /// 6. Ensure that the received views contain the newly created "test_view". async fn create_child_view_in_workspace_subscription_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let workspace = test.get_current_workspace().await.workspace; - let mut rx = test + let workspace = test.get_current_workspace().await; + let rx = test .notification_sender .subscribe::<RepeatedViewPB>(&workspace.id, FolderNotification::DidUpdateWorkspaceViews); let cloned_test = test.clone(); let cloned_workspace_id = workspace.id.clone(); - tokio::spawn(async move { + test.inner.dispatcher().spawn(async move { cloned_test .create_view(&cloned_workspace_id, "workspace child view".to_string()) .await; }); - let views = receive_with_timeout(&mut rx, Duration::from_secs(30)) + let views = receive_with_timeout(rx, Duration::from_secs(30)) .await .unwrap() .items; @@ -41,16 +41,16 @@ async fn create_child_view_in_workspace_subscription_test() { #[tokio::test] async fn create_child_view_in_view_subscription_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let mut workspace = test.get_current_workspace().await.workspace; + let mut workspace = test.get_current_workspace().await; let workspace_child_view = workspace.views.pop().unwrap(); - let mut rx = test.notification_sender.subscribe::<ChildViewUpdatePB>( + let rx = test.notification_sender.subscribe::<ChildViewUpdatePB>( &workspace_child_view.id, FolderNotification::DidUpdateChildViews, ); let cloned_test = test.clone(); let child_view_id = workspace_child_view.id.clone(); - tokio::spawn(async move { + test.inner.dispatcher().spawn(async move { cloned_test .create_view( &child_view_id, @@ -59,7 +59,7 @@ async fn create_child_view_in_view_subscription_test() { .await; }); - let update = receive_with_timeout(&mut rx, Duration::from_secs(30)) + let update = receive_with_timeout(rx, Duration::from_secs(30)) .await .unwrap(); @@ -73,21 +73,30 @@ async fn create_child_view_in_view_subscription_test() { #[tokio::test] async fn delete_view_subscription_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let workspace = test.get_current_workspace().await.workspace; - let mut rx = test + let workspace = test.get_current_workspace().await; + let rx = test .notification_sender .subscribe::<ChildViewUpdatePB>(&workspace.id, FolderNotification::DidUpdateChildViews); let cloned_test = test.clone(); let delete_view_id = workspace.views.first().unwrap().id.clone(); let cloned_delete_view_id = delete_view_id.clone(); - tokio::spawn(async move { - cloned_test.delete_view(&cloned_delete_view_id).await; - }); + test + .inner + .dispatcher() + .spawn(async move { + cloned_test.delete_view(&cloned_delete_view_id).await; + }) + .await + .unwrap(); - let update = receive_with_timeout(&mut rx, Duration::from_secs(30)) + let update = test + .inner + .dispatcher() + .run_until(receive_with_timeout(rx, Duration::from_secs(30))) .await .unwrap(); + assert_eq!(update.delete_child_views.len(), 1); assert_eq!(update.delete_child_views[0], delete_view_id); } @@ -95,8 +104,8 @@ async fn delete_view_subscription_test() { #[tokio::test] async fn update_view_subscription_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let mut workspace = test.get_current_workspace().await.workspace; - let mut rx = test + let mut workspace = test.get_current_workspace().await; + let rx = test .notification_sender .subscribe::<ChildViewUpdatePB>(&workspace.id, FolderNotification::DidUpdateChildViews); @@ -105,7 +114,7 @@ async fn update_view_subscription_test() { assert!(!view.is_favorite); let update_view_id = view.id.clone(); - tokio::spawn(async move { + test.inner.dispatcher().spawn(async move { cloned_test .update_view(UpdateViewPayloadPB { view_id: update_view_id, @@ -116,7 +125,7 @@ async fn update_view_subscription_test() { .await; }); - let update = receive_with_timeout(&mut rx, Duration::from_secs(30)) + let update = receive_with_timeout(rx, Duration::from_secs(30)) .await .unwrap(); assert_eq!(update.update_child_views.len(), 1); diff --git a/frontend/rust-lib/event-integration/tests/folder/local_test/test.rs b/frontend/rust-lib/event-integration/tests/folder/local_test/test.rs index 87cc6b82d9e6..24b64110c3be 100644 --- a/frontend/rust-lib/event-integration/tests/folder/local_test/test.rs +++ b/frontend/rust-lib/event-integration/tests/folder/local_test/test.rs @@ -16,44 +16,45 @@ async fn create_workspace_event_test() { .payload(request) .async_send() .await - .parse::<flowy_folder2::entities::WorkspacePB>(); - assert_eq!(resp.name, "my second workspace"); + .error() + .unwrap(); + assert_eq!(resp.code, ErrorCode::NotSupportYet); } -#[tokio::test] -async fn open_workspace_event_test() { - let test = EventIntegrationTest::new_with_guest_user().await; - let payload = CreateWorkspacePayloadPB { - name: "my second workspace".to_owned(), - desc: "".to_owned(), - }; - // create a workspace - let resp_1 = EventBuilder::new(test.clone()) - .event(flowy_folder2::event_map::FolderEvent::CreateWorkspace) - .payload(payload) - .async_send() - .await - .parse::<flowy_folder2::entities::WorkspacePB>(); - - // open the workspace - let payload = WorkspaceIdPB { - value: Some(resp_1.id.clone()), - }; - let resp_2 = EventBuilder::new(test) - .event(flowy_folder2::event_map::FolderEvent::OpenWorkspace) - .payload(payload) - .async_send() - .await - .parse::<flowy_folder2::entities::WorkspacePB>(); - - assert_eq!(resp_1.id, resp_2.id); - assert_eq!(resp_1.name, resp_2.name); -} +// #[tokio::test] +// async fn open_workspace_event_test() { +// let test = EventIntegrationTest::new_with_guest_user().await; +// let payload = CreateWorkspacePayloadPB { +// name: "my second workspace".to_owned(), +// desc: "".to_owned(), +// }; +// // create a workspace +// let resp_1 = EventBuilder::new(test.clone()) +// .event(flowy_folder2::event_map::FolderEvent::CreateWorkspace) +// .payload(payload) +// .async_send() +// .await +// .parse::<flowy_folder2::entities::WorkspacePB>(); +// +// // open the workspace +// let payload = WorkspaceIdPB { +// value: Some(resp_1.id.clone()), +// }; +// let resp_2 = EventBuilder::new(test) +// .event(flowy_folder2::event_map::FolderEvent::OpenWorkspace) +// .payload(payload) +// .async_send() +// .await +// .parse::<flowy_folder2::entities::WorkspacePB>(); +// +// assert_eq!(resp_1.id, resp_2.id); +// assert_eq!(resp_1.name, resp_2.name); +// } #[tokio::test] async fn create_view_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let view = test .create_view(¤t_workspace.id, "My first view".to_string()) .await; @@ -65,7 +66,7 @@ async fn create_view_event_test() { #[tokio::test] async fn update_view_event_with_name_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let view = test .create_view(¤t_workspace.id, "My first view".to_string()) .await; @@ -86,7 +87,7 @@ async fn update_view_event_with_name_test() { #[tokio::test] async fn update_view_icon_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let view = test .create_view(¤t_workspace.id, "My first view".to_string()) .await; @@ -110,7 +111,7 @@ async fn update_view_icon_event_test() { #[tokio::test] async fn delete_view_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let view = test .create_view(¤t_workspace.id, "My first view".to_string()) .await; @@ -133,7 +134,7 @@ async fn delete_view_event_test() { #[tokio::test] async fn put_back_trash_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let view = test .create_view(¤t_workspace.id, "My first view".to_string()) .await; @@ -176,7 +177,7 @@ async fn put_back_trash_event_test() { #[tokio::test] async fn delete_view_permanently_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let view = test .create_view(¤t_workspace.id, "My first view".to_string()) .await; @@ -225,7 +226,7 @@ async fn delete_view_permanently_event_test() { #[tokio::test] async fn delete_all_trash_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; for i in 0..3 { let view = test @@ -269,7 +270,7 @@ async fn delete_all_trash_test() { #[tokio::test] async fn multiple_hierarchy_view_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; for i in 1..4 { let parent = test .create_view(¤t_workspace.id, format!("My {} view", i)) @@ -345,7 +346,7 @@ async fn multiple_hierarchy_view_test() { #[tokio::test] async fn move_view_event_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; for i in 1..4 { let parent = test .create_view(¤t_workspace.id, format!("My {} view", i)) @@ -383,7 +384,7 @@ async fn move_view_event_test() { #[tokio::test] async fn move_view_event_after_delete_view_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; for i in 1..6 { let _ = test .create_view(¤t_workspace.id, format!("My {} view", i)) @@ -425,7 +426,7 @@ async fn move_view_event_after_delete_view_test() { #[tokio::test] async fn move_view_event_after_delete_view_test2() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let parent = test .create_view(¤t_workspace.id, "My view".to_string()) .await; @@ -466,7 +467,7 @@ async fn move_view_event_after_delete_view_test2() { #[tokio::test] async fn create_parent_view_with_invalid_name() { for (name, code) in invalid_workspace_name_test_case() { - let sdk = EventIntegrationTest::new(); + let sdk = EventIntegrationTest::new().await; let request = CreateWorkspacePayloadPB { name, desc: "".to_owned(), @@ -495,7 +496,7 @@ fn invalid_workspace_name_test_case() -> Vec<(String, ErrorCode)> { #[tokio::test] async fn move_view_across_parent_test() { let test = EventIntegrationTest::new_with_guest_user().await; - let current_workspace = test.get_current_workspace().await.workspace; + let current_workspace = test.get_current_workspace().await; let parent_1 = test .create_view(¤t_workspace.id, "My view 1".to_string()) .await; diff --git a/frontend/rust-lib/event-integration/tests/folder/supabase_test/helper.rs b/frontend/rust-lib/event-integration/tests/folder/supabase_test/helper.rs index e86257c32d0c..171e65fee3d2 100644 --- a/frontend/rust-lib/event-integration/tests/folder/supabase_test/helper.rs +++ b/frontend/rust-lib/event-integration/tests/folder/supabase_test/helper.rs @@ -5,7 +5,7 @@ use collab::core::collab::MutexCollab; use collab::core::origin::CollabOrigin; use collab::preclude::updates::decoder::Decode; use collab::preclude::{merge_updates_v1, JsonValue, Update}; -use collab_folder::core::FolderData; +use collab_folder::FolderData; use event_integration::event_builder::EventBuilder; use flowy_folder2::entities::{FolderSnapshotPB, RepeatedFolderSnapshotPB, WorkspaceIdPB}; @@ -19,7 +19,7 @@ pub struct FlowySupabaseFolderTest { impl FlowySupabaseFolderTest { pub async fn new() -> Option<Self> { - let inner = FlowySupabaseTest::new()?; + let inner = FlowySupabaseTest::new().await?; let uuid = uuid::Uuid::new_v4().to_string(); let _ = inner.supabase_sign_up_with_uuid(&uuid, None).await; Some(Self { inner }) @@ -39,7 +39,7 @@ impl FlowySupabaseFolderTest { EventBuilder::new(self.inner.deref().clone()) .event(GetFolderSnapshots) .payload(WorkspaceIdPB { - value: Some(workspace_id.to_string()), + value: workspace_id.to_string(), }) .async_send() .await diff --git a/frontend/rust-lib/event-integration/tests/folder/supabase_test/test.rs b/frontend/rust-lib/event-integration/tests/folder/supabase_test/test.rs index fb4ea0f361c3..4ac2f5446956 100644 --- a/frontend/rust-lib/event-integration/tests/folder/supabase_test/test.rs +++ b/frontend/rust-lib/event-integration/tests/folder/supabase_test/test.rs @@ -12,11 +12,12 @@ use crate::util::{get_folder_data_from_server, receive_with_timeout}; #[tokio::test] async fn supabase_encrypt_folder_test() { if let Some(test) = FlowySupabaseFolderTest::new().await { + let uid = test.user_manager.user_id().unwrap(); let secret = test.enable_encryption().await; let local_folder_data = test.get_local_folder_data().await; - let workspace_id = test.get_current_workspace().await.workspace.id; - let remote_folder_data = get_folder_data_from_server(&workspace_id, Some(secret)) + let workspace_id = test.get_current_workspace().await.id; + let remote_folder_data = get_folder_data_from_server(&uid, &workspace_id, Some(secret)) .await .unwrap() .unwrap(); @@ -28,20 +29,21 @@ async fn supabase_encrypt_folder_test() { #[tokio::test] async fn supabase_decrypt_folder_data_test() { if let Some(test) = FlowySupabaseFolderTest::new().await { + let uid = test.user_manager.user_id().unwrap(); let secret = Some(test.enable_encryption().await); - let workspace_id = test.get_current_workspace().await.workspace.id; + let workspace_id = test.get_current_workspace().await.id; test .create_view(&workspace_id, "encrypt view".to_string()) .await; - let mut rx = test + let rx = test .notification_sender .subscribe_with_condition::<FolderSyncStatePB, _>(&workspace_id, |pb| pb.is_finish); - receive_with_timeout(&mut rx, Duration::from_secs(10)) + receive_with_timeout(rx, Duration::from_secs(10)) .await .unwrap(); - let folder_data = get_folder_data_from_server(&workspace_id, secret) + let folder_data = get_folder_data_from_server(&uid, &workspace_id, secret) .await .unwrap() .unwrap(); @@ -54,19 +56,20 @@ async fn supabase_decrypt_folder_data_test() { #[should_panic] async fn supabase_decrypt_with_invalid_secret_folder_data_test() { if let Some(test) = FlowySupabaseFolderTest::new().await { + let uid = test.user_manager.user_id().unwrap(); let _ = Some(test.enable_encryption().await); - let workspace_id = test.get_current_workspace().await.workspace.id; + let workspace_id = test.get_current_workspace().await.id; test .create_view(&workspace_id, "encrypt view".to_string()) .await; - let mut rx = test + let rx = test .notification_sender .subscribe_with_condition::<FolderSyncStatePB, _>(&workspace_id, |pb| pb.is_finish); - receive_with_timeout(&mut rx, Duration::from_secs(10)) + receive_with_timeout(rx, Duration::from_secs(10)) .await .unwrap(); - let _ = get_folder_data_from_server(&workspace_id, Some("invalid secret".to_string())) + let _ = get_folder_data_from_server(&uid, &workspace_id, Some("invalid secret".to_string())) .await .unwrap(); } @@ -74,11 +77,11 @@ async fn supabase_decrypt_with_invalid_secret_folder_data_test() { #[tokio::test] async fn supabase_folder_snapshot_test() { if let Some(test) = FlowySupabaseFolderTest::new().await { - let workspace_id = test.get_current_workspace().await.workspace.id; - let mut rx = test + let workspace_id = test.get_current_workspace().await.id; + let rx = test .notification_sender .subscribe::<FolderSnapshotStatePB>(&workspace_id, DidUpdateFolderSnapshotState); - receive_with_timeout(&mut rx, Duration::from_secs(10)) + receive_with_timeout(rx, Duration::from_secs(10)) .await .unwrap(); @@ -92,7 +95,7 @@ async fn supabase_folder_snapshot_test() { #[tokio::test] async fn supabase_initial_folder_snapshot_test2() { if let Some(test) = FlowySupabaseFolderTest::new().await { - let workspace_id = test.get_current_workspace().await.workspace.id; + let workspace_id = test.get_current_workspace().await.id; test .create_view(&workspace_id, "supabase test view1".to_string()) @@ -104,11 +107,11 @@ async fn supabase_initial_folder_snapshot_test2() { .create_view(&workspace_id, "supabase test view3".to_string()) .await; - let mut rx = test + let rx = test .notification_sender .subscribe_with_condition::<FolderSyncStatePB, _>(&workspace_id, |pb| pb.is_finish); - receive_with_timeout(&mut rx, Duration::from_secs(10)) + receive_with_timeout(rx, Duration::from_secs(10)) .await .unwrap(); diff --git a/frontend/rust-lib/event-integration/tests/user/af_cloud_test/auth_test.rs b/frontend/rust-lib/event-integration/tests/user/af_cloud_test/auth_test.rs index a37eaa1276a9..04c4666e4f00 100644 --- a/frontend/rust-lib/event-integration/tests/user/af_cloud_test/auth_test.rs +++ b/frontend/rust-lib/event-integration/tests/user/af_cloud_test/auth_test.rs @@ -6,7 +6,7 @@ use crate::util::{generate_test_email, get_af_cloud_config}; #[tokio::test] async fn af_cloud_sign_up_test() { if get_af_cloud_config().is_some() { - let test = EventIntegrationTest::new(); + let test = EventIntegrationTest::new().await; let email = generate_test_email(); let user = test.af_cloud_sign_in_with_email(&email).await.unwrap(); assert_eq!(user.email, email); @@ -16,7 +16,7 @@ async fn af_cloud_sign_up_test() { #[tokio::test] async fn af_cloud_update_user_metadata() { if get_af_cloud_config().is_some() { - let test = EventIntegrationTest::new(); + let test = EventIntegrationTest::new().await; let user = test.af_cloud_sign_up().await; let old_profile = test.get_user_profile().await.unwrap(); diff --git a/frontend/rust-lib/event-integration/tests/user/af_cloud_test/member_test.rs b/frontend/rust-lib/event-integration/tests/user/af_cloud_test/member_test.rs index 6020c8a56ae8..577323eae34c 100644 --- a/frontend/rust-lib/event-integration/tests/user/af_cloud_test/member_test.rs +++ b/frontend/rust-lib/event-integration/tests/user/af_cloud_test/member_test.rs @@ -5,10 +5,10 @@ use crate::util::get_af_cloud_config; #[tokio::test] async fn af_cloud_add_workspace_member_test() { if get_af_cloud_config().is_some() { - let test_1 = EventIntegrationTest::new(); + let test_1 = EventIntegrationTest::new().await; let user_1 = test_1.af_cloud_sign_up().await; - let test_2 = EventIntegrationTest::new(); + let test_2 = EventIntegrationTest::new().await; let user_2 = test_2.af_cloud_sign_up().await; let members = test_1.get_workspace_members(&user_1.workspace_id).await; @@ -29,10 +29,10 @@ async fn af_cloud_add_workspace_member_test() { #[tokio::test] async fn af_cloud_delete_workspace_member_test() { if get_af_cloud_config().is_some() { - let test_1 = EventIntegrationTest::new(); + let test_1 = EventIntegrationTest::new().await; let user_1 = test_1.af_cloud_sign_up().await; - let test_2 = EventIntegrationTest::new(); + let test_2 = EventIntegrationTest::new().await; let user_2 = test_2.af_cloud_sign_up().await; test_1 diff --git a/frontend/rust-lib/event-integration/tests/user/local_test/auth_test.rs b/frontend/rust-lib/event-integration/tests/user/local_test/auth_test.rs index c5ee9c556061..9aa35dd344b0 100644 --- a/frontend/rust-lib/event-integration/tests/user/local_test/auth_test.rs +++ b/frontend/rust-lib/event-integration/tests/user/local_test/auth_test.rs @@ -9,7 +9,7 @@ use crate::user::local_test::helper::*; #[tokio::test] async fn sign_up_with_invalid_email() { for email in invalid_email_test_case() { - let sdk = EventIntegrationTest::new(); + let sdk = EventIntegrationTest::new().await; let request = SignUpPayloadPB { email: email.to_string(), name: valid_name(), @@ -33,7 +33,7 @@ async fn sign_up_with_invalid_email() { } #[tokio::test] async fn sign_up_with_long_password() { - let sdk = EventIntegrationTest::new(); + let sdk = EventIntegrationTest::new().await; let request = SignUpPayloadPB { email: unique_email(), name: valid_name(), @@ -58,7 +58,7 @@ async fn sign_up_with_long_password() { #[tokio::test] async fn sign_in_with_invalid_email() { for email in invalid_email_test_case() { - let sdk = EventIntegrationTest::new(); + let sdk = EventIntegrationTest::new().await; let request = SignInPayloadPB { email: email.to_string(), password: login_password(), @@ -84,7 +84,7 @@ async fn sign_in_with_invalid_email() { #[tokio::test] async fn sign_in_with_invalid_password() { for password in invalid_password_test_case() { - let sdk = EventIntegrationTest::new(); + let sdk = EventIntegrationTest::new().await; let request = SignInPayloadPB { email: unique_email(), diff --git a/frontend/rust-lib/event-integration/tests/user/local_test/user_awareness_test.rs b/frontend/rust-lib/event-integration/tests/user/local_test/user_awareness_test.rs index 68bae5c7a4b7..8e1223f56642 100644 --- a/frontend/rust-lib/event-integration/tests/user/local_test/user_awareness_test.rs +++ b/frontend/rust-lib/event-integration/tests/user/local_test/user_awareness_test.rs @@ -6,8 +6,8 @@ use flowy_user::entities::{ReminderPB, RepeatedReminderPB}; use flowy_user::event_map::UserEvent::*; #[tokio::test] -async fn user_update_with_name() { - let sdk = EventIntegrationTest::new(); +async fn user_update_with_reminder() { + let sdk = EventIntegrationTest::new().await; let _ = sdk.sign_up_as_guest().await; let mut meta = HashMap::new(); meta.insert("object_id".to_string(), "".to_string()); diff --git a/frontend/rust-lib/event-integration/tests/user/local_test/user_profile_test.rs b/frontend/rust-lib/event-integration/tests/user/local_test/user_profile_test.rs index 60f5cc84ccf2..7418b267afd4 100644 --- a/frontend/rust-lib/event-integration/tests/user/local_test/user_profile_test.rs +++ b/frontend/rust-lib/event-integration/tests/user/local_test/user_profile_test.rs @@ -10,7 +10,7 @@ use crate::user::local_test::helper::*; #[tokio::test] async fn user_profile_get_failed() { - let sdk = EventIntegrationTest::new(); + let sdk = EventIntegrationTest::new().await; let result = EventBuilder::new(sdk) .event(GetUserProfile) .async_send() @@ -21,11 +21,12 @@ async fn user_profile_get_failed() { #[tokio::test] async fn anon_user_profile_get() { - let test = EventIntegrationTest::new(); + let test = EventIntegrationTest::new().await; let user_profile = test.init_anon_user().await; let user = EventBuilder::new(test.clone()) .event(GetUserProfile) - .sync_send() + .async_send() + .await .parse::<UserProfilePB>(); assert_eq!(user_profile.id, user.id); assert_eq!(user_profile.openai_key, user.openai_key); @@ -36,18 +37,20 @@ async fn anon_user_profile_get() { #[tokio::test] async fn user_update_with_name() { - let sdk = EventIntegrationTest::new(); + let sdk = EventIntegrationTest::new().await; let user = sdk.init_anon_user().await; let new_name = "hello_world".to_owned(); let request = UpdateUserProfilePayloadPB::new(user.id).name(&new_name); let _ = EventBuilder::new(sdk.clone()) .event(UpdateUserProfile) .payload(request) - .sync_send(); + .async_send() + .await; let user_profile = EventBuilder::new(sdk.clone()) .event(GetUserProfile) - .sync_send() + .async_send() + .await .parse::<UserProfilePB>(); assert_eq!(user_profile.name, new_name,); @@ -55,7 +58,7 @@ async fn user_update_with_name() { #[tokio::test] async fn user_update_with_ai_key() { - let sdk = EventIntegrationTest::new(); + let sdk = EventIntegrationTest::new().await; let user = sdk.init_anon_user().await; let openai_key = "openai_key".to_owned(); let stability_ai_key = "stability_ai_key".to_owned(); @@ -65,11 +68,13 @@ async fn user_update_with_ai_key() { let _ = EventBuilder::new(sdk.clone()) .event(UpdateUserProfile) .payload(request) - .sync_send(); + .async_send() + .await; let user_profile = EventBuilder::new(sdk.clone()) .event(GetUserProfile) - .sync_send() + .async_send() + .await .parse::<UserProfilePB>(); assert_eq!(user_profile.openai_key, openai_key,); @@ -78,17 +83,19 @@ async fn user_update_with_ai_key() { #[tokio::test] async fn anon_user_update_with_email() { - let sdk = EventIntegrationTest::new(); + let sdk = EventIntegrationTest::new().await; let user = sdk.init_anon_user().await; let new_email = format!("{}@gmail.com", nanoid!(6)); let request = UpdateUserProfilePayloadPB::new(user.id).email(&new_email); let _ = EventBuilder::new(sdk.clone()) .event(UpdateUserProfile) .payload(request) - .sync_send(); + .async_send() + .await; let user_profile = EventBuilder::new(sdk.clone()) .event(GetUserProfile) - .sync_send() + .async_send() + .await .parse::<UserProfilePB>(); // When the user is anonymous, the email is empty no matter what you set @@ -97,7 +104,7 @@ async fn anon_user_update_with_email() { #[tokio::test] async fn user_update_with_invalid_email() { - let test = EventIntegrationTest::new(); + let test = EventIntegrationTest::new().await; let user = test.init_anon_user().await; for email in invalid_email_test_case() { let request = UpdateUserProfilePayloadPB::new(user.id).email(&email); @@ -105,7 +112,8 @@ async fn user_update_with_invalid_email() { EventBuilder::new(test.clone()) .event(UpdateUserProfile) .payload(request) - .sync_send() + .async_send() + .await .error() .unwrap() .code, @@ -116,7 +124,7 @@ async fn user_update_with_invalid_email() { #[tokio::test] async fn user_update_with_invalid_password() { - let test = EventIntegrationTest::new(); + let test = EventIntegrationTest::new().await; let user = test.init_anon_user().await; for password in invalid_password_test_case() { let request = UpdateUserProfilePayloadPB::new(user.id).password(&password); @@ -133,13 +141,14 @@ async fn user_update_with_invalid_password() { #[tokio::test] async fn user_update_with_invalid_name() { - let test = EventIntegrationTest::new(); + let test = EventIntegrationTest::new().await; let user = test.init_anon_user().await; let request = UpdateUserProfilePayloadPB::new(user.id).name(""); assert!(EventBuilder::new(test.clone()) .event(UpdateUserProfile) .payload(request) - .sync_send() + .async_send() + .await .error() .is_some()) } diff --git a/frontend/rust-lib/event-integration/tests/user/migration_test/document_test.rs b/frontend/rust-lib/event-integration/tests/user/migration_test/document_test.rs index 9f3100b7ca4b..f15b5c3fddc4 100644 --- a/frontend/rust-lib/event-integration/tests/user/migration_test/document_test.rs +++ b/frontend/rust-lib/event-integration/tests/user/migration_test/document_test.rs @@ -11,7 +11,8 @@ async fn migrate_historical_empty_document_test() { "historical_empty_document", ) .unwrap(); - let test = EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()); + let test = + EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()).await; let views = test.get_all_workspace_views().await; assert_eq!(views.len(), 3); diff --git a/frontend/rust-lib/event-integration/tests/user/migration_test/history_user_db/036_fav_v1_workspace_array.zip b/frontend/rust-lib/event-integration/tests/user/migration_test/history_user_db/036_fav_v1_workspace_array.zip new file mode 100644 index 000000000000..2fa72a1e649e Binary files /dev/null and b/frontend/rust-lib/event-integration/tests/user/migration_test/history_user_db/036_fav_v1_workspace_array.zip differ diff --git a/frontend/rust-lib/event-integration/tests/user/migration_test/version_test.rs b/frontend/rust-lib/event-integration/tests/user/migration_test/version_test.rs index ccd1b25e8c4a..5b38c29d0dc2 100644 --- a/frontend/rust-lib/event-integration/tests/user/migration_test/version_test.rs +++ b/frontend/rust-lib/event-integration/tests/user/migration_test/version_test.rs @@ -11,7 +11,8 @@ async fn migrate_020_historical_empty_document_test() { "020_historical_user_data", ) .unwrap(); - let test = EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()); + let test = + EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()).await; let mut views = test.get_all_workspace_views().await; assert_eq!(views.len(), 1); @@ -37,3 +38,25 @@ async fn migrate_020_historical_empty_document_test() { assert_eq!(database.rows.len(), 3); drop(cleaner); } + +#[tokio::test] +async fn migrate_036_fav_v1_workspace_array_test() { + // Used to test migration: FavoriteV1AndWorkspaceArrayMigration + let (cleaner, user_db_path) = unzip_history_user_db( + "./tests/user/migration_test/history_user_db", + "036_fav_v1_workspace_array", + ) + .unwrap(); + let test = + EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()).await; + + let views = test.get_all_workspace_views().await; + assert_eq!(views.len(), 2); + assert_eq!(views[0].name, "root page"); + assert_eq!(views[1].name, "⭐\u{fe0f} Getting started"); + + let views = test.get_views(&views[1].id).await; + assert_eq!(views.child_views.len(), 3); + assert!(views.child_views[2].is_favorite); + drop(cleaner); +} diff --git a/frontend/rust-lib/event-integration/tests/user/supabase_test/auth_test.rs b/frontend/rust-lib/event-integration/tests/user/supabase_test/auth_test.rs index d2bf889a8351..53a0d7117973 100644 --- a/frontend/rust-lib/event-integration/tests/user/supabase_test/auth_test.rs +++ b/frontend/rust-lib/event-integration/tests/user/supabase_test/auth_test.rs @@ -4,7 +4,7 @@ use assert_json_diff::assert_json_eq; use collab_database::rows::database_row_document_id_from_row_id; use collab_document::blocks::DocumentData; use collab_entity::CollabType; -use collab_folder::core::FolderData; +use collab_folder::FolderData; use nanoid::nanoid; use serde_json::json; @@ -13,7 +13,7 @@ use event_integration::event_builder::EventBuilder; use event_integration::EventIntegrationTest; use flowy_core::DEFAULT_NAME; use flowy_encrypt::decrypt_text; -use flowy_server::supabase::define::{USER_EMAIL, USER_UUID}; +use flowy_server::supabase::define::{USER_DEVICE_ID, USER_EMAIL, USER_UUID}; use flowy_user::entities::{AuthTypePB, OauthSignInPB, UpdateUserProfilePayloadPB, UserProfilePB}; use flowy_user::errors::ErrorCode; use flowy_user::event_map::UserEvent::*; @@ -23,13 +23,14 @@ use crate::util::*; #[tokio::test] async fn third_party_sign_up_test() { if get_supabase_config().is_some() { - let test = EventIntegrationTest::new(); + let test = EventIntegrationTest::new().await; let mut map = HashMap::new(); map.insert(USER_UUID.to_string(), uuid::Uuid::new_v4().to_string()); map.insert( USER_EMAIL.to_string(), format!("{}@appflowy.io", nanoid!(6)), ); + map.insert(USER_DEVICE_ID.to_string(), uuid::Uuid::new_v4().to_string()); let payload = OauthSignInPB { map, auth_type: AuthTypePB::Supabase, @@ -48,7 +49,7 @@ async fn third_party_sign_up_test() { #[tokio::test] async fn third_party_sign_up_with_encrypt_test() { if get_supabase_config().is_some() { - let test = EventIntegrationTest::new(); + let test = EventIntegrationTest::new().await; test.supabase_party_sign_up().await; let user_profile = test.get_user_profile().await.unwrap(); assert!(user_profile.encryption_sign.is_empty()); @@ -65,11 +66,12 @@ async fn third_party_sign_up_with_encrypt_test() { #[tokio::test] async fn third_party_sign_up_with_duplicated_uuid() { if get_supabase_config().is_some() { - let test = EventIntegrationTest::new(); + let test = EventIntegrationTest::new().await; let email = format!("{}@appflowy.io", nanoid!(6)); let mut map = HashMap::new(); map.insert(USER_UUID.to_string(), uuid::Uuid::new_v4().to_string()); map.insert(USER_EMAIL.to_string(), email.clone()); + map.insert(USER_DEVICE_ID.to_string(), uuid::Uuid::new_v4().to_string()); let response_1 = EventBuilder::new(test.clone()) .event(OauthSignIn) @@ -98,7 +100,7 @@ async fn third_party_sign_up_with_duplicated_uuid() { #[tokio::test] async fn third_party_sign_up_with_duplicated_email() { if get_supabase_config().is_some() { - let test = EventIntegrationTest::new(); + let test = EventIntegrationTest::new().await; let email = format!("{}@appflowy.io", nanoid!(6)); test .supabase_sign_up_with_uuid(&uuid::Uuid::new_v4().to_string(), Some(email.clone())) @@ -138,7 +140,6 @@ async fn sign_up_as_guest_and_then_update_to_new_cloud_user_test() { assert_eq!(old_workspace.views.len(), new_workspace.views.len()); for (index, view) in old_views.iter().enumerate() { assert_eq!(view.name, new_views[index].name); - assert_eq!(view.id, new_views[index].id); assert_eq!(view.layout, new_views[index].layout); assert_eq!(view.create_time, new_views[index].create_time); } @@ -196,7 +197,7 @@ async fn sign_up_as_guest_and_then_update_to_existing_cloud_user_test() { #[tokio::test] async fn get_user_profile_test() { - if let Some(test) = FlowySupabaseTest::new() { + if let Some(test) = FlowySupabaseTest::new().await { let uuid = uuid::Uuid::new_v4().to_string(); test.supabase_sign_up_with_uuid(&uuid, None).await.unwrap(); @@ -207,7 +208,7 @@ async fn get_user_profile_test() { #[tokio::test] async fn update_user_profile_test() { - if let Some(test) = FlowySupabaseTest::new() { + if let Some(test) = FlowySupabaseTest::new().await { let uuid = uuid::Uuid::new_v4().to_string(); let profile = test.supabase_sign_up_with_uuid(&uuid, None).await.unwrap(); test @@ -221,7 +222,7 @@ async fn update_user_profile_test() { #[tokio::test] async fn update_user_profile_with_existing_email_test() { - if let Some(test) = FlowySupabaseTest::new() { + if let Some(test) = FlowySupabaseTest::new().await { let email = format!("{}@appflowy.io", nanoid!(6)); let _ = test .supabase_sign_up_with_uuid(&uuid::Uuid::new_v4().to_string(), Some(email.clone())) @@ -249,7 +250,7 @@ async fn update_user_profile_with_existing_email_test() { #[tokio::test] async fn migrate_anon_document_on_cloud_signup() { if get_supabase_config().is_some() { - let test = EventIntegrationTest::new(); + let test = EventIntegrationTest::new().await; let user_profile = test.sign_up_as_guest().await.user_profile; let view = test @@ -295,28 +296,19 @@ async fn migrate_anon_data_on_cloud_signup() { ) .unwrap(); let test = - EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()); + EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()).await; let user_profile = test.supabase_party_sign_up().await; // Get the folder data from remote let folder_data: FolderData = test .folder_manager .get_cloud_service() - .get_folder_data(&user_profile.workspace_id) + .get_folder_data(&user_profile.workspace_id, &user_profile.id) .await .unwrap() .unwrap(); let expected_folder_data = expected_workspace_sync_folder_data(); - - if folder_data.workspaces.len() != expected_folder_data.workspaces.len() { - dbg!(&folder_data.workspaces); - } - - assert_eq!( - folder_data.workspaces.len(), - expected_folder_data.workspaces.len() - ); assert_eq!(folder_data.views.len(), expected_folder_data.views.len()); // After migration, the ids of the folder_data should be different from the expected_folder_data @@ -328,10 +320,7 @@ async fn migrate_anon_data_on_cloud_signup() { assert_eq!(left_view.name, right_view.name); } - assert_ne!( - folder_data.current_workspace_id, - expected_folder_data.current_workspace_id - ); + assert_ne!(folder_data.workspace.id, expected_folder_data.workspace.id); assert_ne!(folder_data.current_view, expected_folder_data.current_view); let database_views = folder_data diff --git a/frontend/rust-lib/event-integration/tests/user/supabase_test/workspace_test.rs b/frontend/rust-lib/event-integration/tests/user/supabase_test/workspace_test.rs index 49e4e289c7c2..543e76f26201 100644 --- a/frontend/rust-lib/event-integration/tests/user/supabase_test/workspace_test.rs +++ b/frontend/rust-lib/event-integration/tests/user/supabase_test/workspace_test.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use event_integration::{event_builder::EventBuilder, EventIntegrationTest}; use flowy_folder2::entities::WorkspaceSettingPB; -use flowy_folder2::event_map::FolderEvent::GetCurrentWorkspace; +use flowy_folder2::event_map::FolderEvent::GetCurrentWorkspaceSetting; use flowy_server::supabase::define::{USER_EMAIL, USER_UUID}; use flowy_user::entities::{AuthTypePB, OauthSignInPB, UserProfilePB}; use flowy_user::event_map::UserEvent::*; @@ -12,7 +12,7 @@ use crate::util::*; #[tokio::test] async fn initial_workspace_test() { if get_supabase_config().is_some() { - let test = EventIntegrationTest::new(); + let test = EventIntegrationTest::new().await; let mut map = HashMap::new(); map.insert(USER_UUID.to_string(), uuid::Uuid::new_v4().to_string()); map.insert( @@ -32,7 +32,7 @@ async fn initial_workspace_test() { .parse::<UserProfilePB>(); let workspace_settings = EventBuilder::new(test.clone()) - .event(GetCurrentWorkspace) + .event(GetCurrentWorkspaceSetting) .async_send() .await .parse::<WorkspaceSettingPB>(); diff --git a/frontend/rust-lib/event-integration/tests/util.rs b/frontend/rust-lib/event-integration/tests/util.rs index 37a2b20616d8..c0066bbfd7b8 100644 --- a/frontend/rust-lib/event-integration/tests/util.rs +++ b/frontend/rust-lib/event-integration/tests/util.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Error; -use collab_folder::core::FolderData; +use collab_folder::FolderData; use collab_plugins::cloud_storage::RemoteCollabStorage; use nanoid::nanoid; use tokio::sync::mpsc::Receiver; @@ -28,7 +28,7 @@ use flowy_user::errors::FlowyError; use flowy_user::event_map::UserCloudServiceProvider; use flowy_user::event_map::UserEvent::*; use flowy_user_deps::cloud::UserCloudService; -use flowy_user_deps::entities::AuthType; +use flowy_user_deps::entities::Authenticator; pub fn get_supabase_config() -> Option<SupabaseConfiguration> { dotenv::from_path(".env.ci").ok()?; @@ -40,11 +40,13 @@ pub struct FlowySupabaseTest { } impl FlowySupabaseTest { - pub fn new() -> Option<Self> { + pub async fn new() -> Option<Self> { let _ = get_supabase_config()?; - let test = EventIntegrationTest::new(); + let test = EventIntegrationTest::new().await; test.set_auth_type(AuthTypePB::Supabase); - test.server_provider.set_auth_type(AuthType::Supabase); + test + .server_provider + .set_authenticator(Authenticator::Supabase); Some(Self { inner: test }) } @@ -71,12 +73,10 @@ impl Deref for FlowySupabaseTest { } pub async fn receive_with_timeout<T>( - receiver: &mut Receiver<T>, + mut receiver: Receiver<T>, duration: Duration, -) -> Result<T, Box<dyn std::error::Error>> { - let res = timeout(duration, receiver.recv()) - .await? - .ok_or(anyhow::anyhow!("recv timeout"))?; +) -> Result<T, Box<dyn std::error::Error + Send>> { + let res = timeout(duration, receiver.recv()).await.unwrap().unwrap(); Ok(res) } @@ -137,11 +137,12 @@ pub fn encryption_collab_service( } pub async fn get_folder_data_from_server( + uid: &i64, folder_id: &str, encryption_secret: Option<String>, ) -> Result<Option<FolderData>, Error> { let (cloud_service, _encryption) = encryption_folder_service(encryption_secret); - cloud_service.get_folder_data(folder_id).await + cloud_service.get_folder_data(folder_id, uid).await } pub async fn get_folder_snapshots( @@ -206,11 +207,13 @@ pub struct AFCloudTest { } impl AFCloudTest { - pub fn new() -> Option<Self> { + pub async fn new() -> Option<Self> { let _ = get_af_cloud_config()?; - let test = EventIntegrationTest::new(); + let test = EventIntegrationTest::new().await; test.set_auth_type(AuthTypePB::AFCloud); - test.server_provider.set_auth_type(AuthType::AFCloud); + test + .server_provider + .set_authenticator(Authenticator::AFCloud); Some(Self { inner: test }) } @@ -228,6 +231,14 @@ pub fn generate_test_email() -> String { format!("{}@test.com", Uuid::new_v4()) } +/// To run the test, create a .env.ci file in the 'event-integration' directory and set the following environment variables: +/// +/// - `APPFLOWY_CLOUD_BASE_URL=http://localhost:8000` +/// - `APPFLOWY_CLOUD_WS_BASE_URL=ws://localhost:8000/ws` +/// - `APPFLOWY_CLOUD_GOTRUE_URL=http://localhost:9998` +/// +/// - `GOTRUE_ADMIN_EMAIL=admin@example.com` +/// - `GOTRUE_ADMIN_PASSWORD=password` pub fn get_af_cloud_config() -> Option<AFCloudConfiguration> { dotenv::from_filename("./.env.ci").ok()?; AFCloudConfiguration::from_env().ok() diff --git a/frontend/rust-lib/flowy-ai/Cargo.toml b/frontend/rust-lib/flowy-ai/Cargo.toml index a8526484c702..ece06cce6e82 100644 --- a/frontend/rust-lib/flowy-ai/Cargo.toml +++ b/frontend/rust-lib/flowy-ai/Cargo.toml @@ -7,10 +7,10 @@ edition = "2021" [dependencies] reqwest = { version = "0.11", features = ["json"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -anyhow = "1.0.75" +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true lib-infra = { path = "../../../shared-lib/lib-infra" } async-openai = "0.14.2" -tokio = { version = "1.12", features = ["rt", "sync"] } +tokio = { workspace = true, features = ["rt", "sync"] } dotenv = "0.15.0" \ No newline at end of file diff --git a/frontend/rust-lib/flowy-config/Cargo.toml b/frontend/rust-lib/flowy-config/Cargo.toml index 1870a1123000..a4fcf93da4ec 100644 --- a/frontend/rust-lib/flowy-config/Cargo.toml +++ b/frontend/rust-lib/flowy-config/Cargo.toml @@ -12,8 +12,8 @@ lib-dispatch = { workspace = true } flowy-error = { workspace = true } flowy-derive = { path = "../../../shared-lib/flowy-derive" } -protobuf = {version = "2.28.0"} -bytes = { version = "1.5" } +protobuf.workspace = true +bytes.workspace = true strum_macros = "0.21" [build-dependencies] diff --git a/frontend/rust-lib/flowy-core/Cargo.toml b/frontend/rust-lib/flowy-core/Cargo.toml index a843c8ecabb2..3ae5624b7ee7 100644 --- a/frontend/rust-lib/flowy-core/Cargo.toml +++ b/frontend/rust-lib/flowy-core/Cargo.toml @@ -28,24 +28,27 @@ flowy-ai = { workspace = true } collab-entity = { version = "0.1.0" } collab-plugins = { version = "0.1.0" } collab = { version = "0.1.0" } -diesel = { version = "1.4.8", features = ["sqlite"] } -uuid = { version = "1.3.3", features = ["v4"] } +diesel.workspace = true +uuid.workspace = true flowy-storage = { workspace = true } client-api = { version = "0.1.0", features = ["collab-sync"] } -tracing = { version = "0.1", features = ["log"] } +tracing.workspace = true futures-core = { version = "0.3", default-features = false } -bytes = "1.5" -tokio = { version = "1.26", features = ["full"] } -tokio-stream = {version = "0.1.14", features = ["sync"]} -console-subscriber = { version = "0.1.8", optional = true } -parking_lot = "0.12.1" -anyhow = "1.0.75" +bytes.workspace = true +tokio = { workspace = true, features = ["full"] } +tokio-stream = { workspace = true, features = ["sync"]} +console-subscriber = { version = "0.2", optional = true } +parking_lot.workspace = true +anyhow.workspace = true +base64 = "0.21.5" lib-infra = { path = "../../../shared-lib/lib-infra" } -serde = "1.0" -serde_json = "1.0" -serde_repr = "0.1" +serde.workspace = true +serde_json.workspace = true +serde_repr.workspace = true +futures.workspace = true +walkdir = "2.4.0" [features] default = ["rev-sqlite"] @@ -71,4 +74,4 @@ ts = [ ] rev-sqlite = ["flowy-user/rev-sqlite"] openssl_vendored = ["flowy-sqlite/openssl_vendored"] - +single_thread = ["lib-dispatch/single_thread"] diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs index cef0cf1689c3..34f0ab9658be 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs @@ -47,7 +47,7 @@ impl SnapshotPersistence for SnapshotDBImpl { { let conn = pool .get() - .map_err(|e| PersistenceError::Internal(Box::new(e)))?; + .map_err(|e| PersistenceError::Internal(e.into()))?; let desc = match CollabSnapshotTableSql::get_latest_snapshot(&object_id, &conn) { None => Ok("".to_string()), @@ -70,7 +70,7 @@ impl SnapshotPersistence for SnapshotDBImpl { }, &conn, ) - .map_err(|e| PersistenceError::Internal(Box::new(e))); + .map_err(|e| PersistenceError::Internal(e.into())); if let Err(e) = result { tracing::warn!("create snapshot error: {:?}", e); diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps.rs index a3907b2e198c..59547aeb4546 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps.rs @@ -101,11 +101,14 @@ impl FolderOperationHandler for DocumentFolderOperation { FutureResult::new(async move { let mut write_guard = workspace_view_builder.write().await; - // Create a view named "⭐️ Getting started" with built-in README data. + // Create a view named "Getting started" with an icon ⭐️ and the built-in README data. // Don't modify this code unless you know what you are doing. write_guard .with_view_builder(|view_builder| async { - let view = view_builder.with_name("⭐️ Getting started").build(); + let view = view_builder + .with_name("Getting started") + .with_icon("⭐️") + .build(); // create a empty document let json_str = include_str!("../../assets/read_me.json"); let document_pb = JsonToDocumentParser::json_str_to_document(json_str).unwrap(); diff --git a/frontend/rust-lib/flowy-core/src/integrate/log.rs b/frontend/rust-lib/flowy-core/src/integrate/log.rs index cdcacfe940b8..10286ee74942 100644 --- a/frontend/rust-lib/flowy-core/src/integrate/log.rs +++ b/frontend/rust-lib/flowy-core/src/integrate/log.rs @@ -7,7 +7,7 @@ pub(crate) fn init_log(config: &AppFlowyCoreConfig) { if !INIT_LOG.load(Ordering::SeqCst) { INIT_LOG.store(true, Ordering::SeqCst); - let _ = lib_log::Builder::new("AppFlowy-Client", &config.storage_path) + let _ = lib_log::Builder::new("log", &config.storage_path) .env_filter(&config.log_filter) .build(); } @@ -34,6 +34,7 @@ pub(crate) fn create_log_filter(level: String, with_crates: Vec<String>) -> Stri filters.push(format!("flowy_notification={}", "info")); filters.push(format!("lib_infra={}", level)); filters.push(format!("flowy_task={}", level)); + // filters.push(format!("lib_dispatch={}", level)); filters.push(format!("dart_ffi={}", "info")); filters.push(format!("flowy_sqlite={}", "info")); diff --git a/frontend/rust-lib/flowy-core/src/integrate/mod.rs b/frontend/rust-lib/flowy-core/src/integrate/mod.rs index 7484472f5aa5..e929b4716b79 100644 --- a/frontend/rust-lib/flowy-core/src/integrate/mod.rs +++ b/frontend/rust-lib/flowy-core/src/integrate/mod.rs @@ -3,3 +3,4 @@ pub(crate) mod log; pub(crate) mod server; mod trait_impls; pub(crate) mod user; +pub(crate) mod util; diff --git a/frontend/rust-lib/flowy-core/src/integrate/server.rs b/frontend/rust-lib/flowy-core/src/integrate/server.rs index 418890fce5fe..75acca4a18a9 100644 --- a/frontend/rust-lib/flowy-core/src/integrate/server.rs +++ b/frontend/rust-lib/flowy-core/src/integrate/server.rs @@ -5,7 +5,6 @@ use std::sync::{Arc, Weak}; use parking_lot::RwLock; use serde_repr::*; -use collab_integrate::YrsDocAction; use flowy_error::{FlowyError, FlowyResult}; use flowy_server::af_cloud::AFCloudServer; use flowy_server::local_server::{LocalServer, LocalServerDB}; @@ -14,9 +13,7 @@ use flowy_server::{AppFlowyEncryption, AppFlowyServer, EncryptionImpl}; use flowy_server_config::af_cloud_config::AFCloudConfiguration; use flowy_server_config::supabase_config::SupabaseConfiguration; use flowy_sqlite::kv::StorePreferences; -use flowy_user::services::database::{ - get_user_profile, get_user_workspace, open_collab_db, open_user_db, -}; +use flowy_user::services::database::{get_user_profile, get_user_workspace, open_user_db}; use flowy_user_deps::cloud::UserCloudService; use flowy_user_deps::entities::*; @@ -49,7 +46,7 @@ impl Display for ServerType { } } -/// The [ServerProvider] provides list of [AppFlowyServer] base on the [AuthType]. Using +/// The [ServerProvider] provides list of [AppFlowyServer] base on the [Authenticator]. Using /// the auth type, the [ServerProvider] will create a new [AppFlowyServer] if it doesn't /// exist. /// Each server implements the [AppFlowyServer] trait, which provides the [UserCloudService], etc. @@ -69,13 +66,13 @@ pub struct ServerProvider { impl ServerProvider { pub fn new( config: AppFlowyCoreConfig, - provider_type: ServerType, + server_type: ServerType, store_preferences: Weak<StorePreferences>, ) -> Self { let encryption = EncryptionImpl::new(None); Self { config, - server_type: RwLock::new(provider_type), + server_type: RwLock::new(server_type), device_id: Arc::new(RwLock::new(uuid::Uuid::new_v4().to_string())), providers: RwLock::new(HashMap::new()), enable_sync: RwLock::new(true), @@ -118,7 +115,6 @@ impl ServerProvider { }, ServerType::AFCloud => { let config = AFCloudConfiguration::from_env()?; - tracing::trace!("🔑AppFlowy cloud config: {:?}", config); let server = Arc::new(AFCloudServer::new( config, *self.enable_sync.read(), @@ -156,23 +152,32 @@ impl ServerProvider { } } -impl From<AuthType> for ServerType { - fn from(auth_provider: AuthType) -> Self { +impl From<Authenticator> for ServerType { + fn from(auth_provider: Authenticator) -> Self { match auth_provider { - AuthType::Local => ServerType::Local, - AuthType::AFCloud => ServerType::AFCloud, - AuthType::Supabase => ServerType::Supabase, + Authenticator::Local => ServerType::Local, + Authenticator::AFCloud => ServerType::AFCloud, + Authenticator::Supabase => ServerType::Supabase, } } } -impl From<&AuthType> for ServerType { - fn from(auth_provider: &AuthType) -> Self { +impl From<ServerType> for Authenticator { + fn from(ty: ServerType) -> Self { + match ty { + ServerType::Local => Authenticator::Local, + ServerType::AFCloud => Authenticator::AFCloud, + ServerType::Supabase => Authenticator::Supabase, + } + } +} +impl From<&Authenticator> for ServerType { + fn from(auth_provider: &Authenticator) -> Self { Self::from(auth_provider.clone()) } } -pub fn current_server_provider(store_preferences: &Arc<StorePreferences>) -> ServerType { +pub fn current_server_type(store_preferences: &Arc<StorePreferences>) -> ServerType { match store_preferences.get_object::<ServerType>(SERVER_PROVIDER_TYPE_KEY) { None => ServerType::Local, Some(provider_type) => provider_type, @@ -195,14 +200,4 @@ impl LocalServerDB for LocalServerDBImpl { let user_workspace = get_user_workspace(&sqlite_db, uid)?; Ok(user_workspace) } - - fn get_collab_updates(&self, uid: i64, object_id: &str) -> Result<Vec<Vec<u8>>, FlowyError> { - let collab_db = open_collab_db(&self.storage_path, uid)?; - let read_txn = collab_db.read_txn(); - let updates = read_txn.get_all_updates(uid, object_id).map_err(|e| { - FlowyError::internal().with_context(format!("Failed to open collab db: {:?}", e)) - })?; - - Ok(updates) - } } diff --git a/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs b/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs index bd4df22ff4b5..2d2acd457577 100644 --- a/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs +++ b/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs @@ -8,6 +8,7 @@ use collab::core::origin::{CollabClient, CollabOrigin}; use collab::preclude::CollabPlugin; use collab_entity::CollabType; use tokio_stream::wrappers::WatchStream; +use tracing::instrument; use collab_integrate::collab_builder::{CollabPluginContext, CollabSource, CollabStorageProvider}; use collab_integrate::postgres::SupabaseDBPlugin; @@ -17,11 +18,13 @@ use flowy_database_deps::cloud::{ use flowy_document2::deps::DocumentData; use flowy_document_deps::cloud::{DocumentCloudService, DocumentSnapshot}; use flowy_error::FlowyError; -use flowy_folder_deps::cloud::{FolderCloudService, FolderData, FolderSnapshot, Workspace}; +use flowy_folder_deps::cloud::{ + FolderCloudService, FolderData, FolderSnapshot, Workspace, WorkspaceRecord, +}; use flowy_storage::{FileStorageService, StorageObject}; use flowy_user::event_map::UserCloudServiceProvider; use flowy_user_deps::cloud::UserCloudService; -use flowy_user_deps::entities::{AuthType, UserTokenState}; +use flowy_user_deps::entities::{Authenticator, UserTokenState}; use lib_infra::future::{to_fut, Fut, FutureResult}; use crate::integrate::server::{ServerProvider, ServerType, SERVER_PROVIDER_TYPE_KEY}; @@ -80,21 +83,21 @@ impl UserCloudServiceProvider for ServerProvider { self.encryption.write().set_secret(secret); } - /// When user login, the provider type is set by the [AuthType] and save to disk for next use. + /// When user login, the provider type is set by the [Authenticator] and save to disk for next use. /// - /// Each [AuthType] has a corresponding [ServerType]. The [ServerType] is used + /// Each [Authenticator] has a corresponding [ServerType]. The [ServerType] is used /// to create a new [AppFlowyServer] if it doesn't exist. Once the [ServerType] is set, /// it will be used when user open the app again. /// - fn set_auth_type(&self, auth_type: AuthType) { - let server_type: ServerType = auth_type.into(); + fn set_authenticator(&self, authenticator: Authenticator) { + let server_type: ServerType = authenticator.into(); self.set_server_type(server_type.clone()); match self.store_preferences.upgrade() { None => tracing::error!("🔴Failed to update server provider type: store preferences is drop"), Some(store_preferences) => { match store_preferences.set_object(SERVER_PROVIDER_TYPE_KEY, server_type.clone()) { - Ok(_) => tracing::trace!("Update server provider type to: {:?}", server_type), + Ok(_) => tracing::trace!("Set server provider: {:?}", server_type), Err(e) => { tracing::error!("🔴Failed to update server provider type: {:?}", e); }, @@ -103,6 +106,11 @@ impl UserCloudServiceProvider for ServerProvider { } } + fn get_authenticator(&self) -> Authenticator { + let server_type = self.get_server_type(); + Authenticator::from(server_type) + } + fn set_device_id(&self, device_id: &str) { if device_id.is_empty() { tracing::error!("🔴Device id is empty"); @@ -140,13 +148,29 @@ impl FolderCloudService for ServerProvider { FutureResult::new(async move { server?.folder_service().create_workspace(uid, &name).await }) } - fn get_folder_data(&self, workspace_id: &str) -> FutureResult<Option<FolderData>, Error> { + fn open_workspace(&self, workspace_id: &str) -> FutureResult<(), Error> { + let workspace_id = workspace_id.to_string(); + let server = self.get_server(&self.get_server_type()); + FutureResult::new(async move { server?.folder_service().open_workspace(&workspace_id).await }) + } + + fn get_all_workspace(&self) -> FutureResult<Vec<WorkspaceRecord>, Error> { + let server = self.get_server(&self.get_server_type()); + FutureResult::new(async move { server?.folder_service().get_all_workspace().await }) + } + + fn get_folder_data( + &self, + workspace_id: &str, + uid: &i64, + ) -> FutureResult<Option<FolderData>, Error> { + let uid = *uid; let server = self.get_server(&self.get_server_type()); let workspace_id = workspace_id.to_string(); FutureResult::new(async move { server? .folder_service() - .get_folder_data(&workspace_id) + .get_folder_data(&workspace_id, &uid) .await }) } @@ -240,7 +264,7 @@ impl DocumentCloudService for ServerProvider { &self, document_id: &str, workspace_id: &str, - ) -> FutureResult<Vec<Vec<u8>>, Error> { + ) -> FutureResult<Vec<Vec<u8>>, FlowyError> { let workspace_id = workspace_id.to_string(); let document_id = document_id.to_string(); let server = self.get_server(&self.get_server_type()); @@ -291,6 +315,7 @@ impl CollabStorageProvider for ServerProvider { self.get_server_type().into() } + #[instrument(level = "debug", skip(self, context), fields(server_type = %self.get_server_type()))] fn get_plugins(&self, context: CollabPluginContext) -> Fut<Vec<Arc<dyn CollabPlugin>>> { match context { CollabPluginContext::Local => to_fut(async move { vec![] }), @@ -303,7 +328,7 @@ impl CollabStorageProvider for ServerProvider { to_fut(async move { let mut plugins: Vec<Arc<dyn CollabPlugin>> = vec![]; match server.collab_ws_channel(&collab_object.object_id).await { - Ok(Some((channel, ws_connect_state))) => { + Ok(Some((channel, ws_connect_state, is_connected))) => { let origin = CollabOrigin::Client(CollabClient::new( collab_object.uid, collab_object.device_id.clone(), @@ -311,8 +336,9 @@ impl CollabStorageProvider for ServerProvider { let sync_object = SyncObject::from(collab_object); let (sink, stream) = (channel.sink(), channel.stream()); let sink_config = SinkConfig::new() - .send_timeout(6) - .with_strategy(SinkStrategy::FixInterval(Duration::from_secs(2))); + .send_timeout(8) + .with_max_payload_size(1024 * 10) + .with_strategy(SinkStrategy::FixInterval(Duration::from_millis(600))); let sync_plugin = SyncPlugin::new( origin, sync_object, @@ -321,6 +347,7 @@ impl CollabStorageProvider for ServerProvider { sink_config, stream, Some(channel), + !is_connected, ws_connect_state, ); plugins.push(Arc::new(sync_plugin)); diff --git a/frontend/rust-lib/flowy-core/src/integrate/user.rs b/frontend/rust-lib/flowy-core/src/integrate/user.rs index 1cc8841e5013..6375aac0f04d 100644 --- a/frontend/rust-lib/flowy-core/src/integrate/user.rs +++ b/frontend/rust-lib/flowy-core/src/integrate/user.rs @@ -1,15 +1,16 @@ use std::sync::Arc; use anyhow::Context; +use tracing::event; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use flowy_database2::DatabaseManager; use flowy_document2::manager::DocumentManager; use flowy_error::FlowyResult; -use flowy_folder2::manager::{FolderInitializeDataSource, FolderManager}; +use flowy_folder2::manager::{FolderInitDataSource, FolderManager}; use flowy_user::event_map::{UserCloudServiceProvider, UserStatusCallback}; use flowy_user_deps::cloud::UserCloudConfig; -use flowy_user_deps::entities::{AuthType, UserProfile, UserWorkspace}; +use flowy_user_deps::entities::{Authenticator, UserProfile, UserWorkspace}; use lib_infra::future::{to_fut, Fut}; use crate::integrate::server::ServerProvider; @@ -26,7 +27,7 @@ pub(crate) struct UserStatusCallbackImpl { } impl UserStatusCallback for UserStatusCallbackImpl { - fn auth_type_did_changed(&self, _auth_type: AuthType) {} + fn authenticator_did_changed(&self, _auth_type: Authenticator) {} fn did_init( &self, @@ -59,7 +60,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { .initialize( user_id, &user_workspace.id, - FolderInitializeDataSource::LocalDisk { + FolderInitDataSource::LocalDisk { create_if_not_exist: false, }, ) @@ -82,8 +83,9 @@ impl UserStatusCallback for UserStatusCallbackImpl { &self, user_id: i64, user_workspace: &UserWorkspace, - _device_id: &str, + device_id: &str, ) -> Fut<FlowyResult<()>> { + let device_id = device_id.to_owned(); let user_id = user_id.to_owned(); let user_workspace = user_workspace.clone(); let folder_manager = self.folder_manager.clone(); @@ -91,6 +93,13 @@ impl UserStatusCallback for UserStatusCallbackImpl { let document_manager = self.document_manager.clone(); to_fut(async move { + event!( + tracing::Level::TRACE, + "Notify did sign in: latest_workspace: {:?}, device_id: {}", + user_workspace, + device_id + ); + folder_manager .initialize_with_workspace_id(user_id, &user_workspace.id) .await?; @@ -113,8 +122,9 @@ impl UserStatusCallback for UserStatusCallbackImpl { is_new_user: bool, user_profile: &UserProfile, user_workspace: &UserWorkspace, - _device_id: &str, + device_id: &str, ) -> Fut<FlowyResult<()>> { + let device_id = device_id.to_owned(); let user_profile = user_profile.clone(); let folder_manager = self.folder_manager.clone(); let database_manager = self.database_manager.clone(); @@ -122,12 +132,20 @@ impl UserStatusCallback for UserStatusCallbackImpl { let document_manager = self.document_manager.clone(); to_fut(async move { + event!( + tracing::Level::TRACE, + "Notify did sign up: is new: {} user_workspace: {:?}, device_id: {}", + is_new_user, + user_workspace, + device_id + ); + folder_manager .initialize_with_new_user( user_profile.uid, &user_profile.token, is_new_user, - FolderInitializeDataSource::LocalDisk { + FolderInitDataSource::LocalDisk { create_if_not_exist: true, }, &user_workspace.id, diff --git a/frontend/rust-lib/flowy-core/src/integrate/util.rs b/frontend/rust-lib/flowy-core/src/integrate/util.rs new file mode 100644 index 000000000000..7eef3ea29f13 --- /dev/null +++ b/frontend/rust-lib/flowy-core/src/integrate/util.rs @@ -0,0 +1,20 @@ +use std::fs::{self}; +use std::io; +use std::path::Path; + +use walkdir::WalkDir; + +pub fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> { + for entry in WalkDir::new(src).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + let relative_path = path.strip_prefix(src).unwrap(); + let target_path = dst.join(relative_path); + + if path.is_dir() { + fs::create_dir_all(&target_path)?; + } else { + fs::copy(path, target_path)?; + } + } + Ok(()) +} diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index de97c12a51b8..0a422a1a6dd5 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -1,31 +1,35 @@ #![allow(unused_doc_comments)] +use std::path::Path; use std::sync::Weak; use std::time::Duration; use std::{fmt, sync::Arc}; +use base64::Engine; use tokio::sync::RwLock; -use tracing::error; +use tracing::{debug, error, event, info, instrument}; use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabSource}; use flowy_database2::DatabaseManager; use flowy_document2::manager::DocumentManager; use flowy_folder2::manager::FolderManager; +use flowy_server_config::af_cloud_config::AFCloudConfiguration; use flowy_sqlite::kv::StorePreferences; use flowy_storage::FileStorageService; use flowy_task::{TaskDispatcher, TaskRunner}; use flowy_user::event_map::UserCloudServiceProvider; -use flowy_user::manager::{UserManager, UserSessionConfig}; +use flowy_user::manager::{UserManager, UserSessionConfig, URL_SAFE_ENGINE}; use lib_dispatch::prelude::*; -use lib_dispatch::runtime::tokio_default_runtime; +use lib_dispatch::runtime::AFPluginRuntime; use module::make_plugins; pub use module::*; use crate::deps_resolve::*; use crate::integrate::collab_interact::CollabInteractImpl; use crate::integrate::log::{create_log_filter, init_log}; -use crate::integrate::server::{current_server_provider, ServerProvider, ServerType}; +use crate::integrate::server::{current_server_type, ServerProvider, ServerType}; use crate::integrate::user::UserStatusCallbackImpl; +use crate::integrate::util::copy_dir_recursive; mod deps_resolve; mod integrate; @@ -42,22 +46,56 @@ pub struct AppFlowyCoreConfig { /// Panics if the `root` path is not existing pub storage_path: String, log_filter: String, + cloud_config: Option<AFCloudConfiguration>, } impl fmt::Debug for AppFlowyCoreConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("AppFlowyCoreConfig") - .field("storage_path", &self.storage_path) - .finish() + let mut debug = f.debug_struct("AppFlowy Configuration"); + debug.field("storage_path", &self.storage_path); + if let Some(config) = &self.cloud_config { + debug.field("base_url", &config.base_url); + debug.field("ws_url", &config.ws_base_url); + } + debug.finish() } } impl AppFlowyCoreConfig { pub fn new(root: &str, name: String) -> Self { + let cloud_config = AFCloudConfiguration::from_env().ok(); + let storage_path = match &cloud_config { + None => root.to_string(), + Some(config) => { + // Isolate the user data folder by the base url of AppFlowy cloud. This is to avoid + // the user data folder being shared by different AppFlowy cloud. + let server_base64 = URL_SAFE_ENGINE.encode(&config.base_url); + let storage_path = format!("{}_{}", root, server_base64); + + // Copy the user data folder from the root path to the isolated path + // The root path only exists when using the local version of appflowy + if !Path::new(&storage_path).exists() && Path::new(root).exists() { + info!("Copy dir from {} to {}", root, storage_path); + let src = Path::new(root); + match copy_dir_recursive(&src, Path::new(&storage_path)) { + Ok(_) => storage_path, + Err(err) => { + // when the copy dir failed, use the root path as the storage path + error!("Copy dir failed: {}", err); + root.to_string() + }, + } + } else { + storage_path + } + }, + }; + AppFlowyCoreConfig { name, - storage_path: root.to_owned(), + storage_path, log_filter: create_log_filter("info".to_owned(), vec![]), + cloud_config: AFCloudConfiguration::from_env().ok(), } } @@ -82,32 +120,52 @@ pub struct AppFlowyCore { } impl AppFlowyCore { + #[cfg(feature = "single_thread")] + pub async fn new(config: AppFlowyCoreConfig) -> Self { + let runtime = Arc::new(AFPluginRuntime::new().unwrap()); + Self::init(config, runtime).await + } + + #[cfg(not(feature = "single_thread"))] pub fn new(config: AppFlowyCoreConfig) -> Self { - /// The profiling can be used to tracing the performance of the application. - /// Check out the [Link](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/backend/profiling) - /// for more information. - #[cfg(feature = "profiling")] - console_subscriber::init(); + let runtime = Arc::new(AFPluginRuntime::new().unwrap()); + let cloned_runtime = runtime.clone(); + runtime.block_on(Self::init(config, cloned_runtime)) + } - // Init the logger before anything else - init_log(&config); + #[instrument(skip(config, runtime))] + async fn init(config: AppFlowyCoreConfig, runtime: Arc<AFPluginRuntime>) -> Self { + #[allow(clippy::if_same_then_else)] + if cfg!(debug_assertions) { + /// The profiling can be used to tracing the performance of the application. + /// Check out the [Link](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/backend/profiling) + /// for more information. + #[cfg(feature = "profiling")] + console_subscriber::init(); + + // Init the logger before anything else + #[cfg(not(feature = "profiling"))] + init_log(&config); + } else { + init_log(&config); + } // Init the key value database let store_preference = Arc::new(StorePreferences::new(&config.storage_path).unwrap()); - - tracing::info!("🔥 {:?}", &config); - let runtime = tokio_default_runtime().unwrap(); + info!("🔥{:?}", &config); let task_scheduler = TaskDispatcher::new(Duration::from_secs(2)); let task_dispatcher = Arc::new(RwLock::new(task_scheduler)); runtime.spawn(TaskRunner::run(task_dispatcher.clone())); - let provider_type = current_server_provider(&store_preference); + let server_type = current_server_type(&store_preference); + debug!("🔥runtime:{}, server:{}", runtime, server_type); let server_provider = Arc::new(ServerProvider::new( config.clone(), - provider_type, + server_type, Arc::downgrade(&store_preference), )); + event!(tracing::Level::DEBUG, "Init managers",); let ( user_manager, folder_manager, @@ -115,7 +173,7 @@ impl AppFlowyCore { database_manager, document_manager, collab_builder, - ) = runtime.block_on(async { + ) = async { /// The shared collab builder is used to build the [Collab] instance. The plugins will be loaded /// on demand based on the [CollabPluginConfig]. let collab_builder = Arc::new(AppFlowyCollabBuilder::new(server_provider.clone())); @@ -162,7 +220,8 @@ impl AppFlowyCore { document_manager, collab_builder, ) - }); + } + .await; let user_status_callback = UserStatusCallbackImpl { collab_builder, @@ -179,17 +238,14 @@ impl AppFlowyCore { }; let cloned_user_session = Arc::downgrade(&user_manager); - runtime.block_on(async move { - if let Some(user_manager) = cloned_user_session.upgrade() { - if let Err(err) = user_manager - .init(user_status_callback, collab_interact_impl) - .await - { - error!("Init user failed: {}", err) - } + if let Some(user_session) = cloned_user_session.upgrade() { + if let Err(err) = user_session + .init(user_status_callback, collab_interact_impl) + .await + { + error!("Init user failed: {}", err) } - }); - + } let event_dispatcher = Arc::new(AFPluginDispatcher::construct(runtime, || { make_plugins( Arc::downgrade(&folder_manager), diff --git a/frontend/rust-lib/flowy-database-deps/Cargo.toml b/frontend/rust-lib/flowy-database-deps/Cargo.toml index b9200fe5b222..9bd8f911df8b 100644 --- a/frontend/rust-lib/flowy-database-deps/Cargo.toml +++ b/frontend/rust-lib/flowy-database-deps/Cargo.toml @@ -9,4 +9,4 @@ edition = "2021" lib-infra = { path = "../../../shared-lib/lib-infra" } flowy-error = { workspace = true } collab-entity = { version = "0.1.0" } -anyhow = "1.0.71" \ No newline at end of file +anyhow.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-database2/Cargo.toml b/frontend/rust-lib/flowy-database2/Cargo.toml index b4867a5fe30a..ec46cf468e3d 100644 --- a/frontend/rust-lib/flowy-database2/Cargo.toml +++ b/frontend/rust-lib/flowy-database2/Cargo.toml @@ -14,37 +14,37 @@ flowy-database-deps = { workspace = true } flowy-derive = { path = "../../../shared-lib/flowy-derive" } flowy-notification = { workspace = true } -parking_lot = "0.12.1" -protobuf = {version = "2.28.0"} +parking_lot.workspace = true +protobuf.workspace = true flowy-error = { workspace = true, features = ["impl_from_dispatch_error", "impl_from_collab"]} lib-dispatch = { workspace = true } -tokio = { version = "1.26", features = ["sync"] } +tokio = { workspace = true, features = ["sync"] } flowy-task= { workspace = true } -bytes = { version = "1.5" } -tracing = { version = "0.1", features = ["log"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = {version = "1.0"} -serde_repr = "0.1" +bytes.workspace = true +tracing.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_repr.workspace = true lib-infra = { path = "../../../shared-lib/lib-infra" } -chrono = { version = "0.4.31", default-features = false, features = ["clock"] } +chrono = { workspace = true, default-features = false, features = ["clock"] } rust_decimal = "1.28.1" rusty-money = {version = "0.4.1", features = ["iso"]} lazy_static = "1.4.0" indexmap = {version = "1.9.2", features = ["serde"]} url = { version = "2"} fancy-regex = "0.11.0" -futures = "0.3.26" +futures.workspace = true dashmap = "5" -anyhow = "1.0" +anyhow.workspace = true async-stream = "0.3.4" rayon = "1.6.1" nanoid = "0.4.0" -async-trait = "0.1.73" +async-trait.workspace = true chrono-tz = "0.8.2" csv = "1.1.6" - strum = "0.25" strum_macros = "0.25" +lru.workspace = true [dev-dependencies] event-integration = { path = "../event-integration", default-features = false } diff --git a/frontend/rust-lib/flowy-database2/src/entities/board_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/board_entities.rs new file mode 100644 index 000000000000..5edefeb09e3c --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/entities/board_entities.rs @@ -0,0 +1,30 @@ +use flowy_derive::ProtoBuf; + +use crate::services::setting::BoardLayoutSetting; + +#[derive(Debug, Clone, Default, Eq, PartialEq, ProtoBuf)] +pub struct BoardLayoutSettingPB { + #[pb(index = 1)] + pub hide_ungrouped_column: bool, + + #[pb(index = 2)] + pub collapse_hidden_groups: bool, +} + +impl From<BoardLayoutSetting> for BoardLayoutSettingPB { + fn from(setting: BoardLayoutSetting) -> Self { + Self { + hide_ungrouped_column: setting.hide_ungrouped_column, + collapse_hidden_groups: setting.collapse_hidden_groups, + } + } +} + +impl From<BoardLayoutSettingPB> for BoardLayoutSetting { + fn from(setting: BoardLayoutSettingPB) -> Self { + Self { + hide_ungrouped_column: setting.hide_ungrouped_column, + collapse_hidden_groups: setting.collapse_hidden_groups, + } + } +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs index 5d25b290f192..d3a0e7c09604 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs @@ -521,13 +521,6 @@ impl FieldType { self.clone().into() } - pub fn default_cell_width(&self) -> i32 { - match self { - FieldType::DateTime | FieldType::LastEditedTime | FieldType::CreatedTime => 180, - _ => 150, - } - } - pub fn default_name(&self) -> String { let s = match self { FieldType::RichText => "Text", diff --git a/frontend/rust-lib/flowy-database2/src/entities/field_settings_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/field_settings_entities.rs index d7b828c44432..a99560795252 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/field_settings_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/field_settings_entities.rs @@ -15,6 +15,9 @@ pub struct FieldSettingsPB { #[pb(index = 2)] pub visibility: FieldVisibility, + + #[pb(index = 3)] + pub width: i32, } impl From<FieldSettings> for FieldSettingsPB { @@ -22,6 +25,7 @@ impl From<FieldSettings> for FieldSettingsPB { Self { field_id: value.field_id, visibility: value.visibility, + width: value.width, } } } @@ -99,6 +103,9 @@ pub struct FieldSettingsChangesetPB { #[pb(index = 3, one_of)] pub visibility: Option<FieldVisibility>, + + #[pb(index = 4, one_of)] + pub width: Option<i32>, } impl From<FieldSettingsChangesetParams> for FieldSettingsChangesetPB { @@ -107,6 +114,7 @@ impl From<FieldSettingsChangesetParams> for FieldSettingsChangesetPB { view_id: value.view_id, field_id: value.field_id, visibility: value.visibility, + width: value.width, } } } @@ -119,6 +127,7 @@ impl TryFrom<FieldSettingsChangesetPB> for FieldSettingsChangesetParams { view_id: value.view_id, field_id: value.field_id, visibility: value.visibility, + width: value.width, }) } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/group_entities/group.rs b/frontend/rust-lib/flowy-database2/src/entities/group_entities/group.rs index 6e7c2a598fa8..4553cee745b4 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/group_entities/group.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/group_entities/group.rs @@ -5,7 +5,7 @@ use flowy_error::ErrorCode; use crate::entities::parser::NotEmptyStr; use crate::entities::{FieldType, RowMetaPB}; -use crate::services::group::{GroupChangeset, GroupData, GroupSetting, GroupSettingChangeset}; +use crate::services::group::{GroupChangeset, GroupData, GroupSetting}; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] pub struct GroupSettingPB { @@ -14,9 +14,6 @@ pub struct GroupSettingPB { #[pb(index = 2)] pub field_id: String, - - #[pb(index = 3)] - pub hide_ungrouped: bool, } impl std::convert::From<&GroupSetting> for GroupSettingPB { @@ -24,7 +21,6 @@ impl std::convert::From<&GroupSetting> for GroupSettingPB { GroupSettingPB { id: rev.id.clone(), field_id: rev.field_id.clone(), - hide_ungrouped: rev.hide_ungrouped, } } } @@ -52,26 +48,6 @@ impl std::convert::From<Vec<GroupSetting>> for RepeatedGroupSettingPB { } } -#[derive(Debug, Default, ProtoBuf)] -pub struct GroupSettingChangesetPB { - #[pb(index = 1)] - pub view_id: String, - - #[pb(index = 2)] - pub group_configuration_id: String, - - #[pb(index = 3, one_of)] - pub hide_ungrouped: Option<bool>, -} - -impl From<GroupSettingChangesetPB> for GroupSettingChangeset { - fn from(value: GroupSettingChangesetPB) -> Self { - Self { - hide_ungrouped: value.hide_ungrouped, - } - } -} - #[derive(ProtoBuf, Debug, Default, Clone)] pub struct RepeatedGroupPB { #[pb(index = 1)] @@ -221,3 +197,36 @@ impl From<UpdateGroupParams> for GroupChangeset { } } } + +#[derive(Debug, Default, ProtoBuf)] +pub struct CreateGroupPayloadPB { + #[pb(index = 1)] + pub view_id: String, + + #[pb(index = 2)] + pub group_config_id: String, + + #[pb(index = 3)] + pub name: String, +} + +#[derive(Debug, Clone)] +pub struct CreateGroupParams { + pub view_id: String, + pub group_config_id: String, + pub name: String, +} + +impl TryFrom<CreateGroupPayloadPB> for CreateGroupParams { + type Error = ErrorCode; + + fn try_from(value: CreateGroupPayloadPB) -> Result<Self, Self::Error> { + let view_id = NotEmptyStr::parse(value.view_id).map_err(|_| ErrorCode::ViewIdIsInvalid)?; + let name = NotEmptyStr::parse(value.name).map_err(|_| ErrorCode::ViewIdIsInvalid)?; + Ok(CreateGroupParams { + view_id: view_id.0, + group_config_id: value.group_config_id, + name: name.0, + }) + } +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/mod.rs b/frontend/rust-lib/flowy-database2/src/entities/mod.rs index 0afd82595287..7721615a9749 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/mod.rs @@ -1,3 +1,4 @@ +mod board_entities; mod calendar_entities; mod cell_entities; mod database_entities; @@ -16,6 +17,7 @@ mod macros; mod share_entities; mod type_option_entities; +pub use board_entities::*; pub use calendar_entities::*; pub use cell_entities::*; pub use database_entities::*; diff --git a/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs index 2e9e4859e50a..ad362a19d705 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs @@ -59,6 +59,9 @@ pub struct RowMetaPB { #[pb(index = 4, one_of)] pub cover: Option<String>, + + #[pb(index = 5)] + pub is_document_empty: bool, } impl std::convert::From<&RowDetail> for RowMetaPB { @@ -68,6 +71,7 @@ impl std::convert::From<&RowDetail> for RowMetaPB { document_id: row_detail.document_id.clone(), icon: row_detail.meta.icon_url.clone(), cover: row_detail.meta.cover_url.clone(), + is_document_empty: row_detail.meta.is_document_empty, } } } @@ -78,6 +82,7 @@ impl std::convert::From<RowDetail> for RowMetaPB { document_id: row_detail.document_id, icon: row_detail.meta.icon_url, cover: row_detail.meta.cover_url, + is_document_empty: row_detail.meta.is_document_empty, } } } @@ -96,6 +101,9 @@ pub struct UpdateRowMetaChangesetPB { #[pb(index = 4, one_of)] pub cover_url: Option<String>, + + #[pb(index = 5, one_of)] + pub is_document_empty: Option<bool>, } #[derive(Debug)] @@ -104,6 +112,7 @@ pub struct UpdateRowMetaParams { pub view_id: String, pub icon_url: Option<String>, pub cover_url: Option<String>, + pub is_document_empty: Option<bool>, } impl TryInto<UpdateRowMetaParams> for UpdateRowMetaChangesetPB { @@ -122,6 +131,7 @@ impl TryInto<UpdateRowMetaParams> for UpdateRowMetaChangesetPB { view_id, icon_url: self.icon_url, cover_url: self.cover_url, + is_document_empty: self.is_document_empty, }) } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/setting_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/setting_entities.rs index 4a6f3de1a2f4..7bd4284ecbfa 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/setting_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/setting_entities.rs @@ -13,7 +13,9 @@ use crate::entities::{ RepeatedSortPB, UpdateFilterParams, UpdateFilterPayloadPB, UpdateGroupPB, UpdateSortParams, UpdateSortPayloadPB, }; -use crate::services::setting::CalendarLayoutSetting; +use crate::services::setting::{BoardLayoutSetting, CalendarLayoutSetting}; + +use super::BoardLayoutSettingPB; /// [DatabaseViewSettingPB] defines the setting options for the grid. Such as the filter, group, and sort. #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] @@ -151,19 +153,51 @@ pub struct DatabaseLayoutSettingPB { pub layout_type: DatabaseLayoutPB, #[pb(index = 2, one_of)] + pub board: Option<BoardLayoutSettingPB>, + + #[pb(index = 3, one_of)] pub calendar: Option<CalendarLayoutSettingPB>, } +impl DatabaseLayoutSettingPB { + pub fn from_board(layout_setting: BoardLayoutSetting) -> Self { + Self { + layout_type: DatabaseLayoutPB::Board, + board: Some(layout_setting.into()), + calendar: None, + } + } + + pub fn from_calendar(layout_setting: CalendarLayoutSetting) -> Self { + Self { + layout_type: DatabaseLayoutPB::Calendar, + calendar: Some(layout_setting.into()), + board: None, + } + } +} + #[derive(Debug, Clone, Default)] pub struct LayoutSettingParams { pub layout_type: DatabaseLayout, + pub board: Option<BoardLayoutSetting>, pub calendar: Option<CalendarLayoutSetting>, } +impl LayoutSettingParams { + pub fn new(layout_type: DatabaseLayout) -> Self { + Self { + layout_type, + ..Default::default() + } + } +} + impl From<LayoutSettingParams> for DatabaseLayoutSettingPB { fn from(data: LayoutSettingParams) -> Self { Self { layout_type: data.layout_type.into(), + board: data.board.map(|board| board.into()), calendar: data.calendar.map(|calendar| calendar.into()), } } @@ -178,6 +212,9 @@ pub struct LayoutSettingChangesetPB { pub layout_type: DatabaseLayoutPB, #[pb(index = 3, one_of)] + pub board: Option<BoardLayoutSettingPB>, + + #[pb(index = 4, one_of)] pub calendar: Option<CalendarLayoutSettingPB>, } @@ -185,9 +222,17 @@ pub struct LayoutSettingChangesetPB { pub struct LayoutSettingChangeset { pub view_id: String, pub layout_type: DatabaseLayout, + pub board: Option<BoardLayoutSetting>, pub calendar: Option<CalendarLayoutSetting>, } +impl LayoutSettingChangeset { + pub fn is_valid(&self) -> bool { + self.board.is_some() && self.layout_type == DatabaseLayout::Board + || self.calendar.is_some() && self.layout_type == DatabaseLayout::Calendar + } +} + impl TryInto<LayoutSettingChangeset> for LayoutSettingChangesetPB { type Error = ErrorCode; @@ -199,7 +244,8 @@ impl TryInto<LayoutSettingChangeset> for LayoutSettingChangesetPB { Ok(LayoutSettingChangeset { view_id, layout_type: self.layout_type.into(), - calendar: self.calendar.map(|calendar| calendar.into()), + board: self.board.map(Into::into), + calendar: self.calendar.map(Into::into), }) } } diff --git a/frontend/rust-lib/flowy-database2/src/event_handler.rs b/frontend/rust-lib/flowy-database2/src/event_handler.rs index b72e2e73c516..ccebbb8fa55a 100644 --- a/frontend/rust-lib/flowy-database2/src/event_handler.rs +++ b/frontend/rust-lib/flowy-database2/src/event_handler.rs @@ -2,9 +2,10 @@ use std::sync::{Arc, Weak}; use collab_database::database::gen_row_id; use collab_database::rows::RowId; +use tokio::sync::oneshot; use flowy_error::{FlowyError, FlowyResult}; -use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; +use lib_dispatch::prelude::{af_spawn, data_result_ok, AFPluginData, AFPluginState, DataResult}; use lib_infra::util::timestamp; use crate::entities::*; @@ -15,7 +16,7 @@ use crate::services::field::{ type_option_data_from_pb_or_default, DateCellChangeset, SelectOptionCellChangeset, }; use crate::services::field_settings::FieldSettingsChangesetParams; -use crate::services::group::{GroupChangeset, GroupChangesets}; +use crate::services::group::GroupChangeset; use crate::services::share::csv::CSVFormat; fn upgrade_manager( @@ -645,36 +646,6 @@ pub(crate) async fn update_date_cell_handler( Ok(()) } -#[tracing::instrument(level = "trace", skip_all, err)] -pub(crate) async fn get_group_configurations_handler( - data: AFPluginData<DatabaseViewIdPB>, - manager: AFPluginState<Weak<DatabaseManager>>, -) -> DataResult<RepeatedGroupSettingPB, FlowyError> { - let manager = upgrade_manager(manager)?; - let params = data.into_inner(); - let database_editor = manager.get_database_with_view_id(params.as_ref()).await?; - let group_configs = database_editor - .get_group_configuration_settings(params.as_ref()) - .await?; - data_result_ok(group_configs.into()) -} - -#[tracing::instrument(level = "trace", skip_all, err)] -pub(crate) async fn update_group_configuration_handler( - data: AFPluginData<GroupSettingChangesetPB>, - manager: AFPluginState<Weak<DatabaseManager>>, -) -> Result<(), FlowyError> { - let manager = upgrade_manager(manager)?; - let params = data.into_inner(); - let view_id = params.view_id.clone(); - let database_editor = manager.get_database_with_view_id(&view_id).await?; - database_editor - .update_group_configuration_setting(&view_id, params.into()) - .await?; - - Ok(()) -} - #[tracing::instrument(level = "trace", skip_all, err)] pub(crate) async fn get_groups_handler( data: AFPluginData<DatabaseViewIdPB>, @@ -725,18 +696,15 @@ pub(crate) async fn update_group_handler( let view_id = params.view_id.clone(); let database_editor = manager.get_database_with_view_id(&view_id).await?; let group_changeset = GroupChangeset::from(params); - database_editor - .update_group(&view_id, group_changeset.clone()) - .await?; - database_editor - .update_group_setting( - &view_id, - GroupChangesets { - update_groups: vec![group_changeset], - }, - ) - .await?; - + let (tx, rx) = oneshot::channel(); + af_spawn(async move { + let result = database_editor + .update_group(&view_id, vec![group_changeset].into()) + .await; + let _ = tx.send(result); + }); + + let _ = rx.await?; Ok(()) } @@ -773,6 +741,20 @@ pub(crate) async fn move_group_row_handler( Ok(()) } +#[tracing::instrument(level = "debug", skip(manager), err)] +pub(crate) async fn create_group_handler( + data: AFPluginData<CreateGroupPayloadPB>, + manager: AFPluginState<Weak<DatabaseManager>>, +) -> FlowyResult<()> { + let manager = upgrade_manager(manager)?; + let params: CreateGroupParams = data.into_inner().try_into()?; + let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + database_editor + .create_group(¶ms.view_id, ¶ms.name) + .await?; + Ok(()) +} + #[tracing::instrument(level = "debug", skip(manager), err)] pub(crate) async fn get_databases_handler( manager: AFPluginState<Weak<DatabaseManager>>, @@ -788,15 +770,11 @@ pub(crate) async fn set_layout_setting_handler( manager: AFPluginState<Weak<DatabaseManager>>, ) -> FlowyResult<()> { let manager = upgrade_manager(manager)?; - let params: LayoutSettingChangeset = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; - let layout_params = LayoutSettingParams { - layout_type: params.layout_type, - calendar: params.calendar, - }; - database_editor - .set_layout_setting(¶ms.view_id, layout_params) - .await; + let changeset = data.into_inner(); + let view_id = changeset.view_id.clone(); + let params: LayoutSettingChangeset = changeset.try_into()?; + let database_editor = manager.get_database_with_view_id(&view_id).await?; + database_editor.set_layout_setting(&view_id, params).await?; Ok(()) } @@ -928,10 +906,8 @@ pub(crate) async fn get_field_settings_handler( let (view_id, field_ids) = data.into_inner().try_into()?; let database_editor = manager.get_database_with_view_id(&view_id).await?; - let layout_ty = database_editor.get_layout_type(view_id.as_ref()).await; - let field_settings = database_editor - .get_field_settings(&view_id, layout_ty, field_ids.clone()) + .get_field_settings(&view_id, field_ids.clone()) .await? .into_iter() .map(FieldSettingsPB::from) @@ -951,10 +927,8 @@ pub(crate) async fn get_all_field_settings_handler( let view_id = data.into_inner(); let database_editor = manager.get_database_with_view_id(view_id.as_ref()).await?; - let layout_ty = database_editor.get_layout_type(view_id.as_ref()).await; - let field_settings = database_editor - .get_all_field_settings(view_id.as_ref(), layout_ty) + .get_all_field_settings(view_id.as_ref()) .await? .into_iter() .map(FieldSettingsPB::from) diff --git a/frontend/rust-lib/flowy-database2/src/event_map.rs b/frontend/rust-lib/flowy-database2/src/event_map.rs index 594feac09c68..436a9ec814ee 100644 --- a/frontend/rust-lib/flowy-database2/src/event_map.rs +++ b/frontend/rust-lib/flowy-database2/src/event_map.rs @@ -54,14 +54,13 @@ pub fn init(database_manager: Weak<DatabaseManager>) -> AFPlugin { // Date .event(DatabaseEvent::UpdateDateCell, update_date_cell_handler) // Group - .event(DatabaseEvent::GetGroupConfigurations, get_group_configurations_handler) - .event(DatabaseEvent::UpdateGroupConfiguration, update_group_configuration_handler) .event(DatabaseEvent::SetGroupByField, set_group_by_field_handler) .event(DatabaseEvent::MoveGroup, move_group_handler) .event(DatabaseEvent::MoveGroupRow, move_group_row_handler) .event(DatabaseEvent::GetGroups, get_groups_handler) .event(DatabaseEvent::GetGroup, get_group_handler) .event(DatabaseEvent::UpdateGroup, update_group_handler) + .event(DatabaseEvent::CreateGroup, create_group_handler) // Database .event(DatabaseEvent::GetDatabases, get_databases_handler) // Calendar @@ -266,14 +265,10 @@ pub enum DatabaseEvent { #[event(input = "DateChangesetPB")] UpdateDateCell = 80, - #[event(input = "DatabaseViewIdPB", output = "RepeatedGroupSettingPB")] - GetGroupConfigurations = 90, - - #[event(input = "GroupSettingChangesetPB")] - UpdateGroupConfiguration = 91, - + /// [SetGroupByField] event is used to create a new grouping in a database + /// view based on the `field_id` #[event(input = "GroupByFieldPayloadPB")] - SetGroupByField = 92, + SetGroupByField = 90, #[event(input = "DatabaseViewIdPB", output = "RepeatedGroupPB")] GetGroups = 100, @@ -290,6 +285,9 @@ pub enum DatabaseEvent { #[event(input = "UpdateGroupPB")] UpdateGroup = 113, + #[event(input = "CreateGroupPayloadPB")] + CreateGroup = 114, + /// Returns all the databases #[event(output = "RepeatedDatabaseDescriptionPB")] GetDatabases = 120, diff --git a/frontend/rust-lib/flowy-database2/src/manager.rs b/frontend/rust-lib/flowy-database2/src/manager.rs index 10734ba8a50a..480c0e993fe2 100644 --- a/frontend/rust-lib/flowy-database2/src/manager.rs +++ b/frontend/rust-lib/flowy-database2/src/manager.rs @@ -1,9 +1,9 @@ -use std::collections::HashMap; +use std::num::NonZeroUsize; use std::sync::{Arc, Weak}; use collab::core::collab::{CollabRawData, MutexCollab}; use collab_database::blocks::BlockEvent; -use collab_database::database::{DatabaseData, YrsDocAction}; +use collab_database::database::{DatabaseData, MutexDatabase, YrsDocAction}; use collab_database::error::DatabaseError; use collab_database::user::{ CollabFuture, CollabObjectUpdate, CollabObjectUpdateByOid, DatabaseCollabService, @@ -12,14 +12,16 @@ use collab_database::user::{ use collab_database::views::{CreateDatabaseParams, CreateViewParams, DatabaseLayout}; use collab_entity::CollabType; use futures::executor::block_on; -use tokio::sync::RwLock; -use tracing::{instrument, trace}; +use lru::LruCache; +use tokio::sync::{Mutex, RwLock}; +use tracing::{event, instrument, trace}; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::{CollabPersistenceConfig, RocksCollabDB}; use flowy_database_deps::cloud::DatabaseCloudService; use flowy_error::{internal_error, FlowyError, FlowyResult}; use flowy_task::TaskDispatcher; +use lib_dispatch::prelude::af_spawn; use crate::entities::{ DatabaseDescriptionPB, DatabaseLayoutPB, DatabaseSnapshotPB, DidFetchRowPB, @@ -41,7 +43,7 @@ pub struct DatabaseManager { user: Arc<dyn DatabaseUser>, workspace_database: Arc<RwLock<Option<Arc<WorkspaceDatabase>>>>, task_scheduler: Arc<RwLock<TaskDispatcher>>, - editors: RwLock<HashMap<String, Arc<DatabaseEditor>>>, + editors: Mutex<LruCache<String, Arc<DatabaseEditor>>>, collab_builder: Arc<AppFlowyCollabBuilder>, cloud_service: Arc<dyn DatabaseCloudService>, } @@ -53,11 +55,12 @@ impl DatabaseManager { collab_builder: Arc<AppFlowyCollabBuilder>, cloud_service: Arc<dyn DatabaseCloudService>, ) -> Self { + let editors = Mutex::new(LruCache::new(NonZeroUsize::new(5).unwrap())); Self { user: database_user, workspace_database: Default::default(), task_scheduler, - editors: Default::default(), + editors, collab_builder, cloud_service, } @@ -79,6 +82,12 @@ impl DatabaseManager { workspace_id: String, database_views_aggregate_id: String, ) -> FlowyResult<()> { + // Clear all existing tasks + self.task_scheduler.write().await.clear_task(); + // Release all existing editors + self.editors.lock().await.clear(); + *self.workspace_database.write().await = None; + let collab_db = self.user.collab_db(uid)?; let collab_builder = UserDatabaseCollabServiceImpl { workspace_id: workspace_id.clone(), @@ -113,7 +122,11 @@ impl DatabaseManager { } // Construct the workspace database. - trace!("open workspace database: {}", &database_views_aggregate_id); + event!( + tracing::Level::INFO, + "open aggregate database views object: {}", + &database_views_aggregate_id + ); let collab = collab_builder.build_collab_with_config( uid, &database_views_aggregate_id, @@ -124,11 +137,8 @@ impl DatabaseManager { ); let workspace_database = WorkspaceDatabase::open(uid, collab, collab_db, config, collab_builder); - subscribe_block_event(&workspace_database); *self.workspace_database.write().await = Some(Arc::new(workspace_database)); - // Remove all existing editors - self.editors.write().await.clear(); Ok(()) } @@ -176,44 +186,42 @@ impl DatabaseManager { } pub async fn get_database(&self, database_id: &str) -> FlowyResult<Arc<DatabaseEditor>> { - if let Some(editor) = self.editors.read().await.get(database_id) { - return Ok(editor.clone()); + if let Some(editor) = self.editors.lock().await.get(database_id).cloned() { + return Ok(editor); } self.open_database(database_id).await } pub async fn open_database(&self, database_id: &str) -> FlowyResult<Arc<DatabaseEditor>> { trace!("create new editor for database {}", database_id); - let mut editors = self.editors.write().await; - - let wdb = self.get_workspace_database().await?; - let database = wdb + let database = self + .get_workspace_database() + .await? .get_database(database_id) .await .ok_or_else(FlowyError::collab_not_sync)?; + // Subscribe the [BlockEvent] + subscribe_block_event(&database); + let editor = Arc::new(DatabaseEditor::new(database, self.task_scheduler.clone()).await?); - editors.insert(database_id.to_string(), editor.clone()); + self + .editors + .lock() + .await + .put(database_id.to_string(), editor.clone()); Ok(editor) } #[tracing::instrument(level = "debug", skip_all)] pub async fn close_database_view<T: AsRef<str>>(&self, view_id: T) -> FlowyResult<()> { - // TODO(natan): defer closing the database if the sync is not finished let view_id = view_id.as_ref(); let wdb = self.get_workspace_database().await?; let database_id = wdb.get_database_id_with_view_id(view_id); - if database_id.is_some() { - wdb.close_database(database_id.as_ref().unwrap()); - } - if let Some(database_id) = database_id { - let mut editors = self.editors.write().await; + let mut editors = self.editors.lock().await; if let Some(editor) = editors.get(&database_id) { - if editor.close_view_editor(view_id).await { - editor.close().await; - editors.remove(&database_id); - } + editor.close_view_editor(view_id).await; } } @@ -359,9 +367,9 @@ impl DatabaseManager { } /// Send notification to all clients that are listening to the given object. -fn subscribe_block_event(workspace_database: &WorkspaceDatabase) { - let mut block_event_rx = workspace_database.subscribe_block_event(); - tokio::spawn(async move { +fn subscribe_block_event(database: &Arc<MutexDatabase>) { + let mut block_event_rx = database.lock().subscribe_block_event(); + af_spawn(async move { while let Ok(event) = block_event_rx.recv().await { match event { BlockEvent::DidFetchRow(row_details) => { diff --git a/frontend/rust-lib/flowy-database2/src/notification.rs b/frontend/rust-lib/flowy-database2/src/notification.rs index acf90e471704..5d7e7e1df44c 100644 --- a/frontend/rust-lib/flowy-database2/src/notification.rs +++ b/frontend/rust-lib/flowy-database2/src/notification.rs @@ -20,8 +20,6 @@ pub enum DatabaseNotification { DidUpdateCell = 40, /// Trigger after editing a field properties including rename,update type option, etc DidUpdateField = 50, - /// Trigger after the group configuration is changed - DidUpdateGroupConfiguration = 59, /// Trigger after the number of groups is changed DidUpdateNumOfGroups = 60, /// Trigger after inserting/deleting/updating/moving a row @@ -42,18 +40,16 @@ pub enum DatabaseNotification { DidUpdateSettings = 70, // Trigger when the layout setting of the database is updated DidUpdateLayoutSettings = 80, - // Trigger when the layout field of the database is changed - DidSetNewLayoutField = 81, // Trigger when the layout of the database is changed - DidUpdateDatabaseLayout = 82, + DidUpdateDatabaseLayout = 81, // Trigger when the database view is deleted - DidDeleteDatabaseView = 83, + DidDeleteDatabaseView = 82, // Trigger when the database view is moved to trash - DidMoveDatabaseViewToTrash = 84, - DidUpdateDatabaseSyncUpdate = 85, - DidUpdateDatabaseSnapshotState = 86, + DidMoveDatabaseViewToTrash = 83, + DidUpdateDatabaseSyncUpdate = 84, + DidUpdateDatabaseSnapshotState = 85, // Trigger when the field setting is changed - DidUpdateFieldSettings = 87, + DidUpdateFieldSettings = 86, } impl std::convert::From<DatabaseNotification> for i32 { @@ -71,7 +67,6 @@ impl std::convert::From<i32> for DatabaseNotification { 22 => DatabaseNotification::DidUpdateFields, 40 => DatabaseNotification::DidUpdateCell, 50 => DatabaseNotification::DidUpdateField, - 59 => DatabaseNotification::DidUpdateGroupConfiguration, 60 => DatabaseNotification::DidUpdateNumOfGroups, 61 => DatabaseNotification::DidUpdateGroupRow, 62 => DatabaseNotification::DidGroupByField, @@ -82,7 +77,6 @@ impl std::convert::From<i32> for DatabaseNotification { 67 => DatabaseNotification::DidUpdateRowMeta, 70 => DatabaseNotification::DidUpdateSettings, 80 => DatabaseNotification::DidUpdateLayoutSettings, - 81 => DatabaseNotification::DidSetNewLayoutField, 82 => DatabaseNotification::DidUpdateDatabaseLayout, 83 => DatabaseNotification::DidDeleteDatabaseView, 84 => DatabaseNotification::DidMoveDatabaseViewToTrash, diff --git a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs index 6bafa9361a2d..9a9476576d2b 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs @@ -8,10 +8,12 @@ use collab_database::rows::{Cell, Cells, CreateRowParams, Row, RowCell, RowDetai use collab_database::views::{DatabaseLayout, DatabaseView, LayoutSetting}; use futures::StreamExt; use tokio::sync::{broadcast, RwLock}; +use tracing::{event, warn}; use flowy_error::{internal_error, ErrorCode, FlowyError, FlowyResult}; use flowy_task::TaskDispatcher; -use lib_infra::future::{to_fut, Fut}; +use lib_dispatch::prelude::af_spawn; +use lib_infra::future::{to_fut, Fut, FutureResult}; use crate::entities::*; use crate::notification::{send_notification, DatabaseNotification}; @@ -20,7 +22,9 @@ use crate::services::cell::{ }; use crate::services::database::util::database_view_setting_pb_from_view; use crate::services::database::UpdatedRow; -use crate::services::database_view::{DatabaseViewChanged, DatabaseViewData, DatabaseViews}; +use crate::services::database_view::{ + DatabaseViewChanged, DatabaseViewEditor, DatabaseViewOperation, DatabaseViews, EditorByViewId, +}; use crate::services::field::checklist_type_option::ChecklistCellChangeset; use crate::services::field::{ default_type_option_data_from_type, select_type_option_from_field, transform_type_option, @@ -31,10 +35,7 @@ use crate::services::field_settings::{ default_field_settings_by_layout_map, FieldSettings, FieldSettingsChangesetParams, }; use crate::services::filter::Filter; -use crate::services::group::{ - default_group_setting, GroupChangeset, GroupChangesets, GroupSetting, GroupSettingChangeset, - RowChangeset, -}; +use crate::services::group::{default_group_setting, GroupChangesets, GroupSetting, RowChangeset}; use crate::services::share::csv::{CSVExport, CSVFormat}; use crate::services::sort::Sort; @@ -51,18 +52,12 @@ impl DatabaseEditor { task_scheduler: Arc<RwLock<TaskDispatcher>>, ) -> FlowyResult<Self> { let cell_cache = AnyTypeCache::<u64>::new(); - let database_view_data = Arc::new(DatabaseViewDataImpl { - database: database.clone(), - task_scheduler: task_scheduler.clone(), - cell_cache: cell_cache.clone(), - }); - let database_id = database.lock().get_database_id(); // Receive database sync state and send to frontend via the notification let mut sync_state = database.lock().subscribe_sync_state(); let cloned_database_id = database_id.clone(); - tokio::spawn(async move { + af_spawn(async move { while let Some(sync_state) = sync_state.next().await { send_notification( &cloned_database_id, @@ -75,7 +70,7 @@ impl DatabaseEditor { // Receive database snapshot state and send to frontend via the notification let mut snapshot_state = database.lock().subscribe_snapshot_state(); - tokio::spawn(async move { + af_spawn(async move { while let Some(snapshot_state) = snapshot_state.next().await { if let Some(new_snapshot_id) = snapshot_state.snapshot_id() { tracing::debug!( @@ -93,8 +88,24 @@ impl DatabaseEditor { } }); - let database_views = - Arc::new(DatabaseViews::new(database.clone(), cell_cache.clone(), database_view_data).await?); + // Used to cache the view of the database for fast access. + let editor_by_view_id = Arc::new(RwLock::new(EditorByViewId::default())); + let view_operation = Arc::new(DatabaseViewOperationImpl { + database: database.clone(), + task_scheduler: task_scheduler.clone(), + cell_cache: cell_cache.clone(), + editor_by_view_id: editor_by_view_id.clone(), + }); + + let database_views = Arc::new( + DatabaseViews::new( + database.clone(), + cell_cache.clone(), + view_operation, + editor_by_view_id, + ) + .await?, + ); Ok(Self { database, cell_cache, @@ -102,13 +113,13 @@ impl DatabaseEditor { }) } + /// Returns bool value indicating whether the database is empty. + /// #[tracing::instrument(level = "debug", skip_all)] pub async fn close_view_editor(&self, view_id: &str) -> bool { self.database_views.close_view(view_id).await } - pub async fn close(&self) {} - pub async fn get_layout_type(&self, view_id: &str) -> DatabaseLayout { let view = self.database_views.get_view_editor(view_id).await.ok(); if let Some(editor) = view { @@ -177,40 +188,9 @@ impl DatabaseEditor { Ok(self.database.lock().delete_view(view_id)) } - pub async fn update_group_setting( - &self, - view_id: &str, - group_setting_changeset: GroupChangesets, - ) -> FlowyResult<()> { + pub async fn update_group(&self, view_id: &str, changesets: GroupChangesets) -> FlowyResult<()> { let view_editor = self.database_views.get_view_editor(view_id).await?; - view_editor - .v_update_group_setting(group_setting_changeset) - .await?; - Ok(()) - } - - pub async fn update_group( - &self, - view_id: &str, - group_changeset: GroupChangeset, - ) -> FlowyResult<()> { - let view_editor = self.database_views.get_view_editor(view_id).await?; - let type_option = view_editor.update_group(group_changeset.clone()).await?; - - if let Some(type_option_data) = type_option { - let field = self.get_field(&group_changeset.field_id); - if field.is_some() { - let _ = self - .update_field_type_option( - view_id, - &group_changeset.field_id, - type_option_data, - field.unwrap(), - ) - .await; - } - } - + view_editor.v_update_group(changesets).await?; Ok(()) } @@ -295,9 +275,7 @@ impl DatabaseEditor { .set_width_at_if_not_none(params.width.map(|value| value as i64)) .set_visibility_if_not_none(params.visibility); }); - self - .notify_did_update_database_field(¶ms.field_id) - .await?; + notify_did_update_database_field(&self.database, ¶ms.field_id)?; Ok(()) } @@ -328,33 +306,18 @@ impl DatabaseEditor { Ok(()) } + /// Update the field type option data. + /// Do nothing if the [TypeOptionData] is empty. pub async fn update_field_type_option( &self, view_id: &str, - field_id: &str, + _field_id: &str, type_option_data: TypeOptionData, old_field: Field, ) -> FlowyResult<()> { - let field_type = FieldType::from(old_field.field_type); - self - .database - .lock() - .fields - .update_field(field_id, |update| { - if old_field.is_primary { - tracing::warn!("Cannot update primary field type"); - } else { - update.update_type_options(|type_options_update| { - type_options_update.insert(&field_type.to_string(), type_option_data); - }); - } - }); + let view_editor = self.database_views.get_view_editor(view_id).await?; + update_field_type_option_fn(&self.database, &view_editor, type_option_data, old_field).await?; - self - .database_views - .did_update_field_type_option(view_id, field_id, &old_field) - .await?; - let _ = self.notify_did_update_database_field(field_id).await; Ok(()) } @@ -398,7 +361,7 @@ impl DatabaseEditor { }, } - self.notify_did_update_database_field(field_id).await?; + notify_did_update_database_field(&self.database, field_id)?; Ok(()) } @@ -435,7 +398,7 @@ impl DatabaseEditor { let params = self.database.lock().duplicate_row(row_id); match params { None => { - tracing::warn!("Failed to duplicate row: {}", row_id); + warn!("Failed to duplicate row: {}", row_id); }, Some(params) => { let _ = self.create_row(view_id, group_id, params).await; @@ -479,7 +442,7 @@ impl DatabaseEditor { let row_detail = self.database.lock().get_row_detail(&row_order.id); if let Some(row_detail) = row_detail { for view in self.database_views.editors().await { - view.v_did_create_row(&row_detail, &group_id, index).await; + view.v_did_create_row(&row_detail, index).await; } return Ok(Some(row_detail)); } @@ -583,9 +546,10 @@ impl DatabaseEditor { document_id: row_document_id, icon: row_meta.icon_url, cover: row_meta.cover_url, + is_document_empty: row_meta.is_document_empty, }) } else { - tracing::warn!("the row:{} is exist in view:{}", row_id.as_str(), view_id); + warn!("the row:{} is exist in view:{}", row_id.as_str(), view_id); None } } @@ -594,7 +558,7 @@ impl DatabaseEditor { if self.database.lock().views.is_row_exist(view_id, row_id) { self.database.lock().get_row_detail(row_id) } else { - tracing::warn!("the row:{} is exist in view:{}", row_id.as_str(), view_id); + warn!("the row:{} is exist in view:{}", row_id.as_str(), view_id); None } } @@ -614,7 +578,8 @@ impl DatabaseEditor { self.database.lock().update_row_meta(row_id, |meta_update| { meta_update .insert_cover_if_not_none(changeset.cover_url) - .insert_icon_if_not_none(changeset.icon_url); + .insert_icon_if_not_none(changeset.icon_url) + .update_is_document_empty_if_not_none(changeset.is_document_empty); }); // Use the temporary row meta to get rid of the lock that not implement the `Send` or 'Sync' trait. @@ -908,40 +873,6 @@ impl DatabaseEditor { Ok(()) } - pub async fn get_group_configuration_settings( - &self, - view_id: &str, - ) -> FlowyResult<Vec<GroupSettingPB>> { - let view = self.database_views.get_view_editor(view_id).await?; - - let group_settings = view - .v_get_group_configuration_settings() - .await - .into_iter() - .map(|value| GroupSettingPB::from(&value)) - .collect::<Vec<GroupSettingPB>>(); - - Ok(group_settings) - } - - pub async fn update_group_configuration_setting( - &self, - view_id: &str, - changeset: GroupSettingChangeset, - ) -> FlowyResult<()> { - let view = self.database_views.get_view_editor(view_id).await?; - let group_configuration = view.v_update_group_configuration_setting(changeset).await?; - - if let Some(configuration) = group_configuration { - let payload: RepeatedGroupSettingPB = vec![configuration].into(); - send_notification(view_id, DatabaseNotification::DidUpdateGroupConfiguration) - .payload(payload) - .send(); - } - - Ok(()) - } - #[tracing::instrument(level = "trace", skip_all, err)] pub async fn load_groups(&self, view_id: &str) -> FlowyResult<RepeatedGroupPB> { let view = self.database_views.get_view_editor(view_id).await?; @@ -984,7 +915,7 @@ impl DatabaseEditor { let row_detail = self.get_row_detail(view_id, &from_row); match row_detail { None => { - tracing::warn!( + warn!( "Move row between group failed, can not find the row:{}", from_row ) @@ -1032,11 +963,21 @@ impl DatabaseEditor { Ok(()) } - pub async fn set_layout_setting(&self, view_id: &str, layout_setting: LayoutSettingParams) { - tracing::trace!("set_layout_setting: {:?}", layout_setting); - if let Ok(view) = self.database_views.get_view_editor(view_id).await { - let _ = view.v_set_layout_settings(layout_setting).await; - }; + pub async fn create_group(&self, view_id: &str, name: &str) -> FlowyResult<()> { + let view_editor = self.database_views.get_view_editor(view_id).await?; + view_editor.v_create_group(name).await?; + Ok(()) + } + + #[tracing::instrument(level = "trace", skip_all)] + pub async fn set_layout_setting( + &self, + view_id: &str, + layout_setting: LayoutSettingChangeset, + ) -> FlowyResult<()> { + let view_editor = self.database_views.get_view_editor(view_id).await?; + view_editor.v_set_layout_settings(layout_setting).await?; + Ok(()) } pub async fn get_layout_setting( @@ -1054,7 +995,7 @@ impl DatabaseEditor { match self.database_views.get_view_editor(view_id).await { Ok(view) => view.v_get_all_calendar_events().await.unwrap_or_default(), Err(_) => { - tracing::warn!("Can not find the view: {}", view_id); + warn!("Can not find the view: {}", view_id); vec![] }, } @@ -1066,7 +1007,7 @@ impl DatabaseEditor { view_id: &str, ) -> FlowyResult<Vec<NoDateCalendarEventPB>> { let _database_view = self.database_views.get_view_editor(view_id).await?; - todo!() + Ok(vec![]) } #[tracing::instrument(level = "trace", skip_all)] @@ -1084,28 +1025,6 @@ impl DatabaseEditor { Ok(()) } - #[tracing::instrument(level = "trace", skip_all, err)] - async fn notify_did_update_database_field(&self, field_id: &str) -> FlowyResult<()> { - let (database_id, field) = { - let database = self.database.lock(); - let database_id = database.get_database_id(); - let field = database.fields.get_field(field_id); - (database_id, field) - }; - - if let Some(field) = field { - let updated_field = FieldPB::from(field); - let notified_changeset = - DatabaseFieldChangesetPB::update(&database_id, vec![updated_field.clone()]); - self.notify_did_update_database(notified_changeset).await?; - send_notification(field_id, DatabaseNotification::DidUpdateField) - .payload(updated_field) - .send(); - } - - Ok(()) - } - async fn notify_did_update_database( &self, changeset: DatabaseFieldChangesetPB, @@ -1134,7 +1053,7 @@ impl DatabaseEditor { pub async fn get_database_data(&self, view_id: &str) -> FlowyResult<DatabasePB> { let database_view = self.database_views.get_view_editor(view_id).await?; let view = database_view - .get_view() + .v_get_view() .await .ok_or_else(FlowyError::record_not_found)?; let rows = database_view.v_get_rows().await; @@ -1179,57 +1098,27 @@ impl DatabaseEditor { pub async fn get_field_settings( &self, view_id: &str, - layout_ty: DatabaseLayout, field_ids: Vec<String>, ) -> FlowyResult<Vec<FieldSettings>> { let view = self.database_views.get_view_editor(view_id).await?; - let default_field_settings = default_field_settings_by_layout_map() - .get(&layout_ty) - .unwrap() - .to_owned(); - let found_field_settings = view.v_get_field_settings(&field_ids).await; - - let field_settings = field_ids - .into_iter() - .map(|field_id| { - if let Some(field_settings) = found_field_settings.get(&field_id) { - field_settings.to_owned() - } else { - FieldSettings::try_from_anymap(field_id, default_field_settings.clone()).unwrap() - } - }) + let field_settings = view + .v_get_field_settings(&field_ids) + .await + .into_values() .collect(); Ok(field_settings) } - pub async fn get_all_field_settings( - &self, - view_id: &str, - layout_ty: DatabaseLayout, - ) -> FlowyResult<Vec<FieldSettings>> { - let view = self.database_views.get_view_editor(view_id).await?; - let default_field_settings = default_field_settings_by_layout_map() - .get(&layout_ty) - .unwrap() - .to_owned(); - let fields = self.get_fields(view_id, None); - - let found_field_settings = view.v_get_all_field_settings().await; - - let field_settings = fields - .into_iter() - .map(|field| { - if let Some(field_settings) = found_field_settings.get(&field.id) { - field_settings.to_owned() - } else { - FieldSettings::try_from_anymap(field.id, default_field_settings.clone()).unwrap() - } - }) + pub async fn get_all_field_settings(&self, view_id: &str) -> FlowyResult<Vec<FieldSettings>> { + let field_ids = self + .get_fields(view_id, None) + .iter() + .map(|field| field.id.clone()) .collect(); - Ok(field_settings) + self.get_field_settings(view_id, field_ids).await } pub async fn update_field_settings_with_changeset( @@ -1238,7 +1127,12 @@ impl DatabaseEditor { ) -> FlowyResult<()> { let view = self.database_views.get_view_editor(¶ms.view_id).await?; view - .v_update_field_settings(¶ms.view_id, ¶ms.field_id, params.visibility) + .v_update_field_settings( + ¶ms.view_id, + ¶ms.field_id, + params.visibility, + params.width, + ) .await?; Ok(()) @@ -1284,13 +1178,14 @@ fn cell_changesets_from_cell_by_field_id( .collect() } -struct DatabaseViewDataImpl { +struct DatabaseViewOperationImpl { database: Arc<MutexDatabase>, task_scheduler: Arc<RwLock<TaskDispatcher>>, cell_cache: CellCache, + editor_by_view_id: Arc<RwLock<EditorByViewId>>, } -impl DatabaseViewData for DatabaseViewDataImpl { +impl DatabaseViewOperation for DatabaseViewOperationImpl { fn get_database(&self) -> Arc<MutexDatabase> { self.database.clone() } @@ -1305,14 +1200,8 @@ impl DatabaseViewData for DatabaseViewDataImpl { to_fut(async move { fields.into_iter().map(Arc::new).collect() }) } - fn get_field(&self, field_id: &str) -> Fut<Option<Arc<Field>>> { - let field = self - .database - .lock() - .fields - .get_field(field_id) - .map(Arc::new); - to_fut(async move { field }) + fn get_field(&self, field_id: &str) -> Option<Field> { + self.database.lock().fields.get_field(field_id) } fn create_field( @@ -1336,6 +1225,29 @@ impl DatabaseViewData for DatabaseViewDataImpl { to_fut(async move { field }) } + fn update_field( + &self, + view_id: &str, + type_option_data: TypeOptionData, + old_field: Field, + ) -> FutureResult<(), FlowyError> { + let view_id = view_id.to_string(); + let weak_editor_by_view_id = Arc::downgrade(&self.editor_by_view_id); + let weak_database = Arc::downgrade(&self.database); + FutureResult::new(async move { + if let (Some(database), Some(editor_by_view_id)) = + (weak_database.upgrade(), weak_editor_by_view_id.upgrade()) + { + let view_editor = editor_by_view_id.read().await.get(&view_id).cloned(); + if let Some(view_editor) = view_editor { + let _ = + update_field_type_option_fn(&database, &view_editor, type_option_data, old_field).await; + } + } + Ok(()) + }) + } + fn get_primary_field(&self) -> Fut<Option<Arc<Field>>> { let field = self .database @@ -1363,14 +1275,42 @@ impl DatabaseViewData for DatabaseViewDataImpl { } fn get_rows(&self, view_id: &str) -> Fut<Vec<Arc<RowDetail>>> { - let database = self.database.lock(); - let rows = database.get_rows_for_view(view_id); - let row_details = rows - .into_iter() - .flat_map(|row| database.get_row_detail(&row.id)) - .collect::<Vec<RowDetail>>(); + let database = self.database.clone(); + let view_id = view_id.to_string(); + to_fut(async move { + let cloned_database = database.clone(); + // offloads the blocking operation to a thread where blocking is acceptable. This prevents + // blocking the main asynchronous runtime + let row_orders = tokio::task::spawn_blocking(move || { + cloned_database.lock().get_row_orders_for_view(&view_id) + }) + .await + .unwrap_or_default(); + tokio::task::yield_now().await; + + let mut all_rows = vec![]; + + // Loading the rows in chunks of 10 rows in order to prevent blocking the main asynchronous runtime + for chunk in row_orders.chunks(10) { + let cloned_database = database.clone(); + let chunk = chunk.to_vec(); + let rows = tokio::task::spawn_blocking(move || { + let orders = cloned_database.lock().get_rows_from_row_orders(&chunk); + let lock_guard = cloned_database.lock(); + orders + .into_iter() + .flat_map(|row| lock_guard.get_row_detail(&row.id)) + .collect::<Vec<RowDetail>>() + }) + .await + .unwrap_or_default(); + + all_rows.extend(rows); + tokio::task::yield_now().await; + } - to_fut(async move { row_details.into_iter().map(Arc::new).collect() }) + all_rows.into_iter().map(Arc::new).collect() + }) } fn get_cells_for_field(&self, view_id: &str, field_id: &str) -> Fut<Vec<Arc<RowCell>>> { @@ -1488,38 +1428,37 @@ impl DatabaseViewData for DatabaseViewDataImpl { view_id: &str, field_ids: &[String], ) -> HashMap<String, FieldSettings> { - let field_settings_map = self - .database - .lock() - .get_field_settings(view_id, Some(field_ids)); - - field_settings_map - .into_iter() - .filter_map(|(field_id, field_settings)| { - let field_settings = FieldSettings::try_from_anymap(field_id.clone(), field_settings); - if let Ok(settings) = field_settings { - Some((field_id, settings)) - } else { - None - } - }) - .collect() - } + let (layout_type, field_settings_map) = { + let database = self.database.lock(); + let layout_type = database.views.get_database_view_layout(view_id); + let field_settings_map = database.get_field_settings(view_id, Some(field_ids)); + (layout_type, field_settings_map) + }; - fn get_all_field_settings(&self, view_id: &str) -> HashMap<String, FieldSettings> { - let field_settings_map = self.database.lock().get_field_settings(view_id, None); + let default_field_settings = default_field_settings_by_layout_map() + .get(&layout_type) + .unwrap() + .to_owned(); - field_settings_map - .into_iter() - .filter_map(|(field_id, field_settings)| { - let field_settings = FieldSettings::try_from_anymap(field_id.clone(), field_settings); - if let Ok(settings) = field_settings { - Some((field_id, settings)) + let field_settings = field_ids + .iter() + .map(|field_id| { + if !field_settings_map.contains_key(field_id) { + let field_settings = + FieldSettings::from_anymap(field_id, layout_type, &default_field_settings); + (field_id.clone(), field_settings) } else { - None + let field_settings = FieldSettings::from_anymap( + field_id, + layout_type, + field_settings_map.get(field_id).unwrap(), + ); + (field_id.clone(), field_settings) } }) - .collect() + .collect(); + + field_settings } fn update_field_settings( @@ -1527,25 +1466,29 @@ impl DatabaseViewData for DatabaseViewDataImpl { view_id: &str, field_id: &str, visibility: Option<FieldVisibility>, + width: Option<i32>, ) { let field_settings_map = self.get_field_settings(view_id, &[field_id.to_string()]); let new_field_settings = if let Some(field_settings) = field_settings_map.get(field_id) { - let mut field_settings = field_settings.to_owned(); - field_settings.visibility = visibility.unwrap_or(field_settings.visibility); - field_settings + FieldSettings { + field_id: field_settings.field_id.clone(), + visibility: visibility.unwrap_or(field_settings.visibility.clone()), + width: width.unwrap_or(field_settings.width), + } } else { - let layout_ty = self.get_layout_for_view(view_id); - let mut field_settings = FieldSettings::try_from_anymap( - field_id.to_string(), - default_field_settings_by_layout_map() - .get(&layout_ty) - .unwrap() - .to_owned(), - ) - .unwrap(); - field_settings.visibility = visibility.unwrap_or(field_settings.visibility); - field_settings + let layout_type = self.get_layout_for_view(view_id); + let default_field_settings = default_field_settings_by_layout_map() + .get(&layout_type) + .unwrap() + .to_owned(); + let field_settings = + FieldSettings::from_anymap(field_id, layout_type, &default_field_settings); + FieldSettings { + field_id: field_settings.field_id.clone(), + visibility: visibility.unwrap_or(field_settings.visibility), + width: width.unwrap_or(field_settings.width), + } }; self.database.lock().update_field_settings( @@ -1559,3 +1502,73 @@ impl DatabaseViewData for DatabaseViewDataImpl { .send() } } + +#[tracing::instrument(level = "trace", skip_all, err)] +pub async fn update_field_type_option_fn( + database: &Arc<MutexDatabase>, + view_editor: &Arc<DatabaseViewEditor>, + type_option_data: TypeOptionData, + old_field: Field, +) -> FlowyResult<()> { + if type_option_data.is_empty() { + warn!("Update type option with empty data"); + return Ok(()); + } + let field_type = FieldType::from(old_field.field_type); + database + .lock() + .fields + .update_field(&old_field.id, |update| { + if old_field.is_primary { + warn!("Cannot update primary field type"); + } else { + update.update_type_options(|type_options_update| { + event!( + tracing::Level::TRACE, + "insert type option to field type: {:?}", + field_type + ); + type_options_update.insert(&field_type.to_string(), type_option_data); + }); + } + }); + + let _ = notify_did_update_database_field(database, &old_field.id); + view_editor + .v_did_update_field_type_option(&old_field) + .await?; + Ok(()) +} + +#[tracing::instrument(level = "trace", skip_all, err)] +fn notify_did_update_database_field( + database: &Arc<MutexDatabase>, + field_id: &str, +) -> FlowyResult<()> { + let (database_id, field, views) = { + let database = database + .try_lock() + .ok_or(FlowyError::internal().with_context("fail to acquire the lock of database"))?; + let database_id = database.get_database_id(); + let field = database.fields.get_field(field_id); + let views = database.get_all_views_description(); + (database_id, field, views) + }; + + if let Some(field) = field { + let updated_field = FieldPB::from(field); + let notified_changeset = + DatabaseFieldChangesetPB::update(&database_id, vec![updated_field.clone()]); + + for view in views { + send_notification(&view.id, DatabaseNotification::DidUpdateFields) + .payload(notified_changeset.clone()) + .send(); + } + + send_notification(field_id, DatabaseNotification::DidUpdateField) + .payload(updated_field) + .send(); + } + Ok(()) +} diff --git a/frontend/rust-lib/flowy-database2/src/services/database/util.rs b/frontend/rust-lib/flowy-database2/src/services/database/util.rs index 393a8f0c1af1..057cdea5e8fa 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database/util.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database/util.rs @@ -1,23 +1,27 @@ -use collab_database::views::DatabaseView; +use collab_database::views::{DatabaseLayout, DatabaseView}; use crate::entities::{ - CalendarLayoutSettingPB, DatabaseLayoutPB, DatabaseLayoutSettingPB, DatabaseViewSettingPB, - FieldSettingsPB, FilterPB, GroupSettingPB, SortPB, + DatabaseLayoutPB, DatabaseLayoutSettingPB, DatabaseViewSettingPB, FieldSettingsPB, FilterPB, + GroupSettingPB, SortPB, }; use crate::services::field_settings::FieldSettings; use crate::services::filter::Filter; use crate::services::group::GroupSetting; -use crate::services::setting::CalendarLayoutSetting; use crate::services::sort::Sort; pub(crate) fn database_view_setting_pb_from_view(view: DatabaseView) -> DatabaseViewSettingPB { let layout_type: DatabaseLayoutPB = view.layout.into(); let layout_setting = if let Some(layout_setting) = view.layout_settings.get(&view.layout) { - let calendar_setting = - CalendarLayoutSettingPB::from(CalendarLayoutSetting::from(layout_setting.clone())); - DatabaseLayoutSettingPB { - layout_type: layout_type.clone(), - calendar: Some(calendar_setting), + match view.layout { + DatabaseLayout::Board => { + let board_setting = layout_setting.clone().into(); + DatabaseLayoutSettingPB::from_board(board_setting) + }, + DatabaseLayout::Calendar => { + let calendar_setting = layout_setting.clone().into(); + DatabaseLayoutSettingPB::from_calendar(calendar_setting) + }, + _ => DatabaseLayoutSettingPB::default(), } } else { DatabaseLayoutSettingPB::default() @@ -54,7 +58,9 @@ pub(crate) fn database_view_setting_pb_from_view(view: DatabaseView) -> Database .field_settings .into_inner() .into_iter() - .flat_map(|(field_id, field_settings)| FieldSettings::try_from_anymap(field_id, field_settings)) + .map(|(field_id, field_settings)| { + FieldSettings::from_anymap(&field_id, view.layout, &field_settings) + }) .map(FieldSettingsPB::from) .collect::<Vec<FieldSettingsPB>>(); diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/layout_deps.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/layout_deps.rs index 4171be414696..711ca4d33fb8 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/layout_deps.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/layout_deps.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use crate::entities::FieldType; use crate::services::field::{DateTypeOption, SingleSelectTypeOption}; use crate::services::field_settings::default_field_settings_by_layout_map; -use crate::services::setting::CalendarLayoutSetting; +use crate::services::setting::{BoardLayoutSetting, CalendarLayoutSetting}; /// When creating a database, we need to resolve the dependencies of the views. /// Different database views have different dependencies. For example, a board @@ -32,6 +32,7 @@ impl DatabaseLayoutDepsResolver { match self.database_layout { DatabaseLayout::Grid => (None, None), DatabaseLayout::Board => { + let layout_settings = BoardLayoutSetting::new().into(); if !self .database .lock() @@ -40,9 +41,9 @@ impl DatabaseLayoutDepsResolver { .any(|field| FieldType::from(field.field_type).can_be_group()) { let select_field = self.create_select_field(); - (Some(select_field), None) + (Some(select_field), Some(layout_settings)) } else { - (None, None) + (None, Some(layout_settings)) } }, DatabaseLayout::Calendar => { @@ -74,7 +75,9 @@ impl DatabaseLayoutDepsResolver { // Insert the layout setting if it's not exist match &self.database_layout { DatabaseLayout::Grid => {}, - DatabaseLayout::Board => {}, + DatabaseLayout::Board => { + self.create_board_layout_setting_if_need(view_id); + }, DatabaseLayout::Calendar => { let date_field_id = match fields .into_iter() @@ -97,6 +100,21 @@ impl DatabaseLayoutDepsResolver { } } + fn create_board_layout_setting_if_need(&self, view_id: &str) { + if self + .database + .lock() + .get_layout_setting::<BoardLayoutSetting>(view_id, &self.database_layout) + .is_none() + { + let layout_setting = BoardLayoutSetting::new(); + self + .database + .lock() + .insert_layout_setting(view_id, &self.database_layout, layout_setting); + } + } + fn create_calendar_layout_setting_if_need(&self, view_id: &str, field_id: &str) { if self .database diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/mod.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/mod.rs index da4ad5bc522c..6522c8e917e1 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/mod.rs @@ -1,6 +1,7 @@ pub use layout_deps::*; pub use notifier::*; pub use view_editor::*; +pub use view_operation::*; pub use views::*; mod layout_deps; @@ -8,6 +9,7 @@ mod notifier; mod view_editor; mod view_filter; mod view_group; +mod view_operation; mod view_sort; mod views; // mod trait_impl; diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/view_editor.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/view_editor.rs index 4c5266c708db..f3052006bfde 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/view_editor.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/view_editor.rs @@ -2,20 +2,20 @@ use std::borrow::Cow; use std::collections::HashMap; use std::sync::Arc; -use collab_database::database::{gen_database_filter_id, gen_database_sort_id, MutexDatabase}; +use collab_database::database::{gen_database_filter_id, gen_database_sort_id}; use collab_database::fields::{Field, TypeOptionData}; -use collab_database::rows::{Cells, Row, RowCell, RowDetail, RowId}; -use collab_database::views::{DatabaseLayout, DatabaseView, LayoutSetting}; +use collab_database::rows::{Cells, Row, RowDetail, RowId}; +use collab_database::views::{DatabaseLayout, DatabaseView}; use tokio::sync::{broadcast, RwLock}; +use tracing::instrument; use flowy_error::{FlowyError, FlowyResult}; -use flowy_task::TaskDispatcher; -use lib_infra::future::Fut; +use lib_dispatch::prelude::af_spawn; use crate::entities::{ CalendarEventPB, DatabaseLayoutMetaPB, DatabaseLayoutSettingPB, DeleteFilterParams, DeleteGroupParams, DeleteSortParams, FieldType, FieldVisibility, GroupChangesPB, GroupPB, - GroupRowsNotificationPB, InsertedRowPB, LayoutSettingParams, RowMetaPB, RowsChangePB, + InsertedRowPB, LayoutSettingChangeset, LayoutSettingParams, RowMetaPB, RowsChangePB, SortChangesetNotificationPB, SortPB, UpdateFilterParams, UpdateSortParams, }; use crate::notification::{send_notification, DatabaseNotification}; @@ -25,124 +25,24 @@ use crate::services::database_view::view_filter::make_filter_controller; use crate::services::database_view::view_group::{ get_cell_for_row, get_cells_for_field, new_group_controller, new_group_controller_with_field, }; +use crate::services::database_view::view_operation::DatabaseViewOperation; use crate::services::database_view::view_sort::make_sort_controller; use crate::services::database_view::{ notify_did_update_filter, notify_did_update_group_rows, notify_did_update_num_of_groups, notify_did_update_setting, notify_did_update_sort, DatabaseLayoutDepsResolver, DatabaseViewChangedNotifier, DatabaseViewChangedReceiverRunner, }; -use crate::services::field::TypeOptionCellDataHandler; use crate::services::field_settings::FieldSettings; use crate::services::filter::{ Filter, FilterChangeset, FilterController, FilterType, UpdatedFilterType, }; -use crate::services::group::{ - GroupChangeset, GroupChangesets, GroupController, GroupSetting, GroupSettingChangeset, - MoveGroupRowContext, RowChangeset, -}; +use crate::services::group::{GroupChangesets, GroupController, MoveGroupRowContext, RowChangeset}; use crate::services::setting::CalendarLayoutSetting; use crate::services::sort::{DeletedSortType, Sort, SortChangeset, SortController, SortType}; -pub trait DatabaseViewData: Send + Sync + 'static { - fn get_database(&self) -> Arc<MutexDatabase>; - - fn get_view(&self, view_id: &str) -> Fut<Option<DatabaseView>>; - /// If the field_ids is None, then it will return all the field revisions - fn get_fields(&self, view_id: &str, field_ids: Option<Vec<String>>) -> Fut<Vec<Arc<Field>>>; - - /// Returns the field with the field_id - fn get_field(&self, field_id: &str) -> Fut<Option<Arc<Field>>>; - - fn create_field( - &self, - view_id: &str, - name: &str, - field_type: FieldType, - type_option_data: TypeOptionData, - ) -> Fut<Field>; - - fn get_primary_field(&self) -> Fut<Option<Arc<Field>>>; - - /// Returns the index of the row with row_id - fn index_of_row(&self, view_id: &str, row_id: &RowId) -> Fut<Option<usize>>; - - /// Returns the `index` and `RowRevision` with row_id - fn get_row(&self, view_id: &str, row_id: &RowId) -> Fut<Option<(usize, Arc<RowDetail>)>>; - - /// Returns all the rows in the view - fn get_rows(&self, view_id: &str) -> Fut<Vec<Arc<RowDetail>>>; - - fn get_cells_for_field(&self, view_id: &str, field_id: &str) -> Fut<Vec<Arc<RowCell>>>; - - fn get_cell_in_row(&self, field_id: &str, row_id: &RowId) -> Fut<Arc<RowCell>>; - - /// Return the database layout type for the view with given view_id - /// The default layout type is [DatabaseLayout::Grid] - fn get_layout_for_view(&self, view_id: &str) -> DatabaseLayout; - - fn get_group_setting(&self, view_id: &str) -> Vec<GroupSetting>; - - fn insert_group_setting(&self, view_id: &str, setting: GroupSetting); - - fn get_sort(&self, view_id: &str, sort_id: &str) -> Option<Sort>; - - fn insert_sort(&self, view_id: &str, sort: Sort); - - fn remove_sort(&self, view_id: &str, sort_id: &str); - - fn get_all_sorts(&self, view_id: &str) -> Vec<Sort>; - - fn remove_all_sorts(&self, view_id: &str); - - fn get_all_filters(&self, view_id: &str) -> Vec<Arc<Filter>>; - - fn delete_filter(&self, view_id: &str, filter_id: &str); - - fn insert_filter(&self, view_id: &str, filter: Filter); - - fn get_filter(&self, view_id: &str, filter_id: &str) -> Option<Filter>; - - fn get_filter_by_field_id(&self, view_id: &str, field_id: &str) -> Option<Filter>; - - fn get_layout_setting(&self, view_id: &str, layout_ty: &DatabaseLayout) -> Option<LayoutSetting>; - - fn insert_layout_setting( - &self, - view_id: &str, - layout_ty: &DatabaseLayout, - layout_setting: LayoutSetting, - ); - - fn update_layout_type(&self, view_id: &str, layout_type: &DatabaseLayout); - - /// Returns a `TaskDispatcher` used to poll a `Task` - fn get_task_scheduler(&self) -> Arc<RwLock<TaskDispatcher>>; - - fn get_type_option_cell_handler( - &self, - field: &Field, - field_type: &FieldType, - ) -> Option<Box<dyn TypeOptionCellDataHandler>>; - - fn get_field_settings( - &self, - view_id: &str, - field_ids: &[String], - ) -> HashMap<String, FieldSettings>; - - fn get_all_field_settings(&self, view_id: &str) -> HashMap<String, FieldSettings>; - - fn update_field_settings( - &self, - view_id: &str, - field_id: &str, - visibility: Option<FieldVisibility>, - ); -} - pub struct DatabaseViewEditor { pub view_id: String, - delegate: Arc<dyn DatabaseViewData>, + delegate: Arc<dyn DatabaseViewOperation>, group_controller: Arc<RwLock<Option<Box<dyn GroupController>>>>, filter_controller: Arc<FilterController>, sort_controller: Arc<RwLock<SortController>>, @@ -158,14 +58,17 @@ impl Drop for DatabaseViewEditor { impl DatabaseViewEditor { pub async fn new( view_id: String, - delegate: Arc<dyn DatabaseViewData>, + delegate: Arc<dyn DatabaseViewOperation>, cell_cache: CellCache, ) -> FlowyResult<Self> { let (notifier, _) = broadcast::channel(100); - tokio::spawn(DatabaseViewChangedReceiverRunner(Some(notifier.subscribe())).run()); - let group_controller = new_group_controller(view_id.clone(), delegate.clone()).await?; - let group_controller = Arc::new(RwLock::new(group_controller)); + af_spawn(DatabaseViewChangedReceiverRunner(Some(notifier.subscribe())).run()); + // Group + let group_controller = Arc::new(RwLock::new( + new_group_controller(view_id.clone(), delegate.clone()).await?, + )); + // Filter let filter_controller = make_filter_controller( &view_id, delegate.clone(), @@ -174,6 +77,7 @@ impl DatabaseViewEditor { ) .await; + // Sort let sort_controller = make_sort_controller( &view_id, delegate.clone(), @@ -198,7 +102,7 @@ impl DatabaseViewEditor { self.filter_controller.close().await; } - pub async fn get_view(&self) -> Option<DatabaseView> { + pub async fn v_get_view(&self) -> Option<DatabaseView> { self.delegate.get_view(&self.view_id).await } @@ -223,39 +127,22 @@ impl DatabaseViewEditor { .send(); } - pub async fn v_did_create_row( - &self, - row_detail: &RowDetail, - group_id: &Option<String>, - index: usize, - ) { - let changes: RowsChangePB; + pub async fn v_did_create_row(&self, row_detail: &RowDetail, index: usize) { // Send the group notification if the current view has groups - match group_id.as_ref() { - None => { - let row = InsertedRowPB::new(RowMetaPB::from(row_detail)).with_index(index as i32); - changes = RowsChangePB::from_insert(row); - }, - Some(group_id) => { - self - .mut_group_controller(|group_controller, _| { - group_controller.did_create_row(row_detail, group_id); - Ok(()) - }) - .await; + if let Some(controller) = self.group_controller.write().await.as_mut() { + let changesets = controller.did_create_row(row_detail, index); - let inserted_row = InsertedRowPB { - row_meta: RowMetaPB::from(row_detail), - index: Some(index as i32), - is_new: true, - }; - let changeset = - GroupRowsNotificationPB::insert(group_id.clone(), vec![inserted_row.clone()]); + for changeset in changesets { notify_did_update_group_rows(changeset).await; - changes = RowsChangePB::from_insert(inserted_row); - }, + } } + let inserted_row = InsertedRowPB { + row_meta: RowMetaPB::from(row_detail), + index: Some(index as i32), + is_new: true, + }; + let changes = RowsChangePB::from_insert(inserted_row); send_notification(&self.view_id, DatabaseNotification::DidUpdateViewRows) .payload(changes) .send(); @@ -265,16 +152,22 @@ impl DatabaseViewEditor { pub async fn v_did_delete_row(&self, row: &Row) { // Send the group notification if the current view has groups; let result = self - .mut_group_controller(|group_controller, field| { - group_controller.did_delete_delete_row(row, &field) - }) + .mut_group_controller(|group_controller, _| group_controller.did_delete_row(row)) .await; if let Some(result) = result { - tracing::trace!("Delete row in view changeset: {:?}", result.row_changesets); + tracing::trace!("Delete row in view changeset: {:?}", result); for changeset in result.row_changesets { notify_did_update_group_rows(changeset).await; } + if let Some(deleted_group) = result.deleted_group { + let payload = GroupChangesPB { + view_id: self.view_id.clone(), + deleted_groups: vec![deleted_group.group_id], + ..Default::default() + }; + notify_did_update_num_of_groups(&self.view_id, payload).await; + } } let changes = RowsChangePB::from_delete(row.id.clone().into_inner()); send_notification(&self.view_id, DatabaseNotification::DidUpdateViewRows) @@ -335,7 +228,7 @@ impl DatabaseViewEditor { let row_id = row_detail.row.id.clone(); let weak_filter_controller = Arc::downgrade(&self.filter_controller); let weak_sort_controller = Arc::downgrade(&self.sort_controller); - tokio::spawn(async move { + af_spawn(async move { if let Some(filter_controller) = weak_filter_controller.upgrade() { filter_controller .did_receive_row_changed(row_id.clone()) @@ -364,6 +257,7 @@ impl DatabaseViewEditor { .await } + #[instrument(level = "info", skip(self))] pub async fn v_get_rows(&self) -> Vec<Arc<RowDetail>> { let mut rows = self.delegate.get_rows(&self.view_id).await; self.v_filter_rows(&mut rows).await; @@ -383,7 +277,7 @@ impl DatabaseViewEditor { let move_row_context = MoveGroupRowContext { row_detail, row_changeset, - field: field.as_ref(), + field: &field, to_group_id, to_row_id, }; @@ -416,9 +310,8 @@ impl DatabaseViewEditor { .read() .await .as_ref()? - .groups() + .get_all_groups() .into_iter() - .filter(|group| group.is_visible) .map(|group_data| GroupPB::from(group_data.clone())) .collect::<Vec<_>>(); tracing::trace!("Number of groups: {}", groups.len()); @@ -468,55 +361,70 @@ impl DatabaseViewEditor { Ok(()) } - pub async fn v_delete_group(&self, _params: DeleteGroupParams) -> FlowyResult<()> { - Ok(()) - } + pub async fn v_create_group(&self, name: &str) -> FlowyResult<()> { + let mut old_field: Option<Field> = None; + let result = if let Some(controller) = self.group_controller.write().await.as_mut() { + let create_group_results = controller.create_group(name.to_string())?; + old_field = self.delegate.get_field(controller.field_id()); + create_group_results + } else { + (None, None) + }; - pub async fn v_update_group_configuration_setting( - &self, - changeset: GroupSettingChangeset, - ) -> FlowyResult<Option<GroupSetting>> { - let result = self - .mut_group_controller(|group_controller, _| { - group_controller.apply_group_configuration_setting_changeset(changeset) - }) - .await; + if let Some(old_field) = old_field { + if let (Some(type_option_data), Some(payload)) = result { + self + .delegate + .update_field(&self.view_id, type_option_data, old_field) + .await?; - Ok(result.flatten()) - } + let group_changes = GroupChangesPB { + view_id: self.view_id.clone(), + inserted_groups: vec![payload], + ..Default::default() + }; + + notify_did_update_num_of_groups(&self.view_id, group_changes).await; + } + } - pub async fn v_update_group_setting(&self, changeset: GroupChangesets) -> FlowyResult<()> { - self - .mut_group_controller(|group_controller, _| { - group_controller.apply_group_setting_changeset(changeset) - }) - .await; Ok(()) } - pub async fn v_get_group_configuration_settings(&self) -> Vec<GroupSetting> { - self.delegate.get_group_setting(&self.view_id) + pub async fn v_delete_group(&self, _params: DeleteGroupParams) -> FlowyResult<()> { + Ok(()) } - pub async fn update_group( - &self, - changeset: GroupChangeset, - ) -> FlowyResult<Option<TypeOptionData>> { - match changeset.name { - Some(group_name) => { - let result = self - .mut_group_controller(|controller, _| { - Ok(controller.update_group_name(&changeset.group_id, &group_name)) - }) - .await; + pub async fn v_update_group(&self, changeset: GroupChangesets) -> FlowyResult<()> { + let mut type_option_data = TypeOptionData::new(); + let (old_field, updated_groups) = if let Some(controller) = + self.group_controller.write().await.as_mut() + { + let old_field = self.delegate.get_field(controller.field_id()); + let (updated_groups, new_type_option) = controller.apply_group_changeset(&changeset).await?; + type_option_data.extend(new_type_option); - match result { - Some(r) => Ok(r), - None => Ok(None), - } - }, - None => Ok(None), + (old_field, updated_groups) + } else { + (None, vec![]) + }; + + if let Some(old_field) = old_field { + if !type_option_data.is_empty() { + self + .delegate + .update_field(&self.view_id, type_option_data, old_field) + .await?; + } + let notification = GroupChangesPB { + view_id: self.view_id.clone(), + update_groups: updated_groups, + ..Default::default() + }; + notify_did_update_num_of_groups(&self.view_id, notification).await; } + + Ok(()) } pub async fn v_get_all_sorts(&self) -> Vec<Sort> { @@ -654,12 +562,16 @@ impl DatabaseViewEditor { let mut layout_setting = LayoutSettingParams::default(); match layout_ty { DatabaseLayout::Grid => {}, - DatabaseLayout::Board => {}, + DatabaseLayout::Board => { + if let Some(value) = self.delegate.get_layout_setting(&self.view_id, layout_ty) { + layout_setting.board = Some(value.into()); + } + }, DatabaseLayout::Calendar => { if let Some(value) = self.delegate.get_layout_setting(&self.view_id, layout_ty) { let calendar_setting = CalendarLayoutSetting::from(value); // Check the field exist or not - if let Some(field) = self.delegate.get_field(&calendar_setting.field_id).await { + if let Some(field) = self.delegate.get_field(&calendar_setting.field_id) { let field_type = FieldType::from(field.field_type); // Check the type of field is Datetime or not @@ -678,64 +590,68 @@ impl DatabaseViewEditor { layout_setting } - /// Update the calendar settings and send the notification to refresh the UI - pub async fn v_set_layout_settings(&self, params: LayoutSettingParams) -> FlowyResult<()> { - // Maybe it needs no send notification to refresh the UI - if let Some(new_calendar_setting) = params.calendar { - if let Some(field) = self - .delegate - .get_field(&new_calendar_setting.field_id) - .await - { - let field_type = FieldType::from(field.field_type); - if field_type != FieldType::DateTime { - return Err(FlowyError::unexpect_calendar_field_type()); - } + /// Update the layout settings and send the notification to refresh the UI + pub async fn v_set_layout_settings(&self, params: LayoutSettingChangeset) -> FlowyResult<()> { + if self.v_get_layout_type().await != params.layout_type || !params.is_valid() { + return Err(FlowyError::invalid_data()); + } - let old_calender_setting = self - .v_get_layout_settings(¶ms.layout_type) - .await - .calendar; + let layout_setting_pb = match params.layout_type { + DatabaseLayout::Board => { + let layout_setting = params.board.unwrap(); self.delegate.insert_layout_setting( &self.view_id, ¶ms.layout_type, - new_calendar_setting.clone().into(), + layout_setting.clone().into(), ); - let new_field_id = new_calendar_setting.field_id.clone(); - let layout_setting_pb: DatabaseLayoutSettingPB = LayoutSettingParams { - layout_type: params.layout_type, - calendar: Some(new_calendar_setting), - } - .into(); - if let Some(old_calendar_setting) = old_calender_setting { - // compare the new layout field id is equal to old layout field id - // if not equal, send the DidSetNewLayoutField notification - // if equal, send the DidUpdateLayoutSettings notification - if old_calendar_setting.field_id != new_field_id { - send_notification(&self.view_id, DatabaseNotification::DidSetNewLayoutField) - .payload(layout_setting_pb.clone()) - .send(); + Some(DatabaseLayoutSettingPB::from_board(layout_setting)) + }, + DatabaseLayout::Calendar => { + let layout_setting = params.calendar.unwrap(); + + if let Some(field) = self.delegate.get_field(&layout_setting.field_id) { + if FieldType::from(field.field_type) != FieldType::DateTime { + return Err(FlowyError::unexpect_calendar_field_type()); } + + self.delegate.insert_layout_setting( + &self.view_id, + ¶ms.layout_type, + layout_setting.clone().into(), + ); + + Some(DatabaseLayoutSettingPB::from_calendar(layout_setting)) + } else { + None } + }, + _ => None, + }; - send_notification(&self.view_id, DatabaseNotification::DidUpdateLayoutSettings) - .payload(layout_setting_pb) - .send(); - } + if let Some(payload) = layout_setting_pb { + send_notification(&self.view_id, DatabaseNotification::DidUpdateLayoutSettings) + .payload(payload) + .send(); } Ok(()) } + /// Notifies the view's field type-option data is changed + /// For the moment, only the groups will be generated after the type-option data changed. A + /// [Field] has a property named type_options contains a list of type-option data. #[tracing::instrument(level = "trace", skip_all, err)] - pub async fn v_did_update_field_type_option( - &self, - field_id: &str, - old_field: &Field, - ) -> FlowyResult<()> { - if let Some(field) = self.delegate.get_field(field_id).await { + pub async fn v_did_update_field_type_option(&self, old_field: &Field) -> FlowyResult<()> { + let field_id = &old_field.id; + // If the id of the grouping field is equal to the updated field's id, then we need to + // update the group setting + if self.is_grouping_field(field_id).await { + self.v_grouping_by_field(field_id).await?; + } + + if let Some(field) = self.delegate.get_field(field_id) { self .sort_controller .read() @@ -760,7 +676,7 @@ impl DatabaseViewEditor { let filter_type = UpdatedFilterType::new(Some(old), new); let filter_changeset = FilterChangeset::from_update(filter_type); let filter_controller = self.filter_controller.clone(); - tokio::spawn(async move { + af_spawn(async move { if let Some(notification) = filter_controller .did_receive_changes(filter_changeset) .await @@ -776,12 +692,16 @@ impl DatabaseViewEditor { /// Called when a grouping field is updated. #[tracing::instrument(level = "debug", skip_all, err)] pub async fn v_grouping_by_field(&self, field_id: &str) -> FlowyResult<()> { - if let Some(field) = self.delegate.get_field(field_id).await { - let new_group_controller = - new_group_controller_with_field(self.view_id.clone(), self.delegate.clone(), field).await?; + if let Some(field) = self.delegate.get_field(field_id) { + let new_group_controller = new_group_controller_with_field( + self.view_id.clone(), + self.delegate.clone(), + Arc::new(field), + ) + .await?; let new_groups = new_group_controller - .groups() + .get_all_groups() .into_iter() .map(|group| GroupPB::from(group.clone())) .collect(); @@ -812,7 +732,7 @@ impl DatabaseViewEditor { let text_cell = get_cell_for_row(self.delegate.clone(), &primary_field.id, &row_id).await?; // Date - let date_field = self.delegate.get_field(&calendar_setting.field_id).await?; + let date_field = self.delegate.get_field(&calendar_setting.field_id)?; let date_cell = get_cell_for_row(self.delegate.clone(), &date_field.id, &row_id).await?; let title = text_cell @@ -954,26 +874,27 @@ impl DatabaseViewEditor { self.delegate.get_field_settings(&self.view_id, field_ids) } - pub async fn v_get_all_field_settings(&self) -> HashMap<String, FieldSettings> { - self.delegate.get_all_field_settings(&self.view_id) - } + // pub async fn v_get_all_field_settings(&self) -> HashMap<String, FieldSettings> { + // self.delegate.get_all_field_settings(&self.view_id) + // } pub async fn v_update_field_settings( &self, view_id: &str, field_id: &str, visibility: Option<FieldVisibility>, + width: Option<i32>, ) -> FlowyResult<()> { self .delegate - .update_field_settings(view_id, field_id, visibility); + .update_field_settings(view_id, field_id, visibility, width); Ok(()) } async fn mut_group_controller<F, T>(&self, f: F) -> Option<T> where - F: FnOnce(&mut Box<dyn GroupController>, Arc<Field>) -> FlowyResult<T>, + F: FnOnce(&mut Box<dyn GroupController>, Field) -> FlowyResult<T>, { let group_field_id = self .group_controller @@ -981,8 +902,7 @@ impl DatabaseViewEditor { .await .as_ref() .map(|group| group.field_id().to_owned())?; - let field = self.delegate.get_field(&group_field_id).await?; - + let field = self.delegate.get_field(&group_field_id)?; let mut write_guard = self.group_controller.write().await; if let Some(group_controller) = &mut *write_guard { f(group_controller, field).ok() diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/view_filter.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/view_filter.rs index eec723e6a0bf..75f31212d961 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/view_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/view_filter.rs @@ -7,13 +7,13 @@ use lib_infra::future::{to_fut, Fut}; use crate::services::cell::CellCache; use crate::services::database_view::{ - gen_handler_id, DatabaseViewChangedNotifier, DatabaseViewData, + gen_handler_id, DatabaseViewChangedNotifier, DatabaseViewOperation, }; use crate::services::filter::{Filter, FilterController, FilterDelegate, FilterTaskHandler}; pub async fn make_filter_controller( view_id: &str, - delegate: Arc<dyn DatabaseViewData>, + delegate: Arc<dyn DatabaseViewOperation>, notifier: DatabaseViewChangedNotifier, cell_cache: CellCache, ) -> Arc<FilterController> { @@ -43,7 +43,7 @@ pub async fn make_filter_controller( filter_controller } -struct DatabaseViewFilterDelegateImpl(Arc<dyn DatabaseViewData>); +struct DatabaseViewFilterDelegateImpl(Arc<dyn DatabaseViewOperation>); impl FilterDelegate for DatabaseViewFilterDelegateImpl { fn get_filter(&self, view_id: &str, filter_id: &str) -> Fut<Option<Arc<Filter>>> { @@ -51,7 +51,7 @@ impl FilterDelegate for DatabaseViewFilterDelegateImpl { to_fut(async move { filter }) } - fn get_field(&self, field_id: &str) -> Fut<Option<Arc<Field>>> { + fn get_field(&self, field_id: &str) -> Option<Field> { self.0.get_field(field_id) } diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/view_group.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/view_group.rs index b79ea3eb3e18..2fb903b0612a 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/view_group.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/view_group.rs @@ -1,40 +1,43 @@ use std::sync::Arc; +use async_trait::async_trait; use collab_database::fields::Field; -use collab_database::rows::RowId; +use collab_database::rows::{Cell, RowId}; use flowy_error::FlowyResult; use lib_infra::future::{to_fut, Fut}; use crate::entities::FieldType; -use crate::services::database_view::DatabaseViewData; +use crate::services::database_view::DatabaseViewOperation; use crate::services::field::RowSingleCellData; use crate::services::group::{ find_new_grouping_field, make_group_controller, GroupController, GroupSetting, - GroupSettingReader, GroupSettingWriter, + GroupSettingReader, GroupSettingWriter, GroupTypeOptionCellOperation, }; pub async fn new_group_controller_with_field( view_id: String, - delegate: Arc<dyn DatabaseViewData>, + delegate: Arc<dyn DatabaseViewOperation>, grouping_field: Arc<Field>, ) -> FlowyResult<Box<dyn GroupController>> { let setting_reader = GroupSettingReaderImpl(delegate.clone()); let rows = delegate.get_rows(&view_id).await; let setting_writer = GroupSettingWriterImpl(delegate.clone()); + let type_option_writer = GroupTypeOptionCellWriterImpl(delegate.clone()); make_group_controller( view_id, grouping_field, rows, setting_reader, setting_writer, + type_option_writer, ) .await } pub async fn new_group_controller( view_id: String, - delegate: Arc<dyn DatabaseViewData>, + delegate: Arc<dyn DatabaseViewOperation>, ) -> FlowyResult<Option<Box<dyn GroupController>>> { let fields = delegate.get_fields(&view_id, None).await; let setting_reader = GroupSettingReaderImpl(delegate.clone()); @@ -59,6 +62,7 @@ pub async fn new_group_controller( if let Some(grouping_field) = grouping_field { let rows = delegate.get_rows(&view_id).await; let setting_writer = GroupSettingWriterImpl(delegate.clone()); + let type_option_writer = GroupTypeOptionCellWriterImpl(delegate.clone()); Ok(Some( make_group_controller( view_id, @@ -66,6 +70,7 @@ pub async fn new_group_controller( rows, setting_reader, setting_writer, + type_option_writer, ) .await?, )) @@ -74,7 +79,7 @@ pub async fn new_group_controller( } } -pub(crate) struct GroupSettingReaderImpl(pub Arc<dyn DatabaseViewData>); +pub(crate) struct GroupSettingReaderImpl(pub Arc<dyn DatabaseViewOperation>); impl GroupSettingReader for GroupSettingReaderImpl { fn get_group_setting(&self, view_id: &str) -> Fut<Option<Arc<GroupSetting>>> { @@ -97,11 +102,11 @@ impl GroupSettingReader for GroupSettingReaderImpl { } pub(crate) async fn get_cell_for_row( - delegate: Arc<dyn DatabaseViewData>, + delegate: Arc<dyn DatabaseViewOperation>, field_id: &str, row_id: &RowId, ) -> Option<RowSingleCellData> { - let field = delegate.get_field(field_id).await?; + let field = delegate.get_field(field_id)?; let row_cell = delegate.get_cell_in_row(field_id, row_id).await; let field_type = FieldType::from(field.field_type); let handler = delegate.get_type_option_cell_handler(&field, &field_type)?; @@ -120,11 +125,11 @@ pub(crate) async fn get_cell_for_row( // Returns the list of cells corresponding to the given field. pub(crate) async fn get_cells_for_field( - delegate: Arc<dyn DatabaseViewData>, + delegate: Arc<dyn DatabaseViewOperation>, view_id: &str, field_id: &str, ) -> Vec<RowSingleCellData> { - if let Some(field) = delegate.get_field(field_id).await { + if let Some(field) = delegate.get_field(field_id) { let field_type = FieldType::from(field.field_type); if let Some(handler) = delegate.get_type_option_cell_handler(&field, &field_type) { let cells = delegate.get_cells_for_field(view_id, field_id).await; @@ -149,11 +154,30 @@ pub(crate) async fn get_cells_for_field( vec![] } -struct GroupSettingWriterImpl(Arc<dyn DatabaseViewData>); - +struct GroupSettingWriterImpl(Arc<dyn DatabaseViewOperation>); impl GroupSettingWriter for GroupSettingWriterImpl { fn save_configuration(&self, view_id: &str, group_setting: GroupSetting) -> Fut<FlowyResult<()>> { self.0.insert_group_setting(view_id, group_setting); to_fut(async move { Ok(()) }) } } + +struct GroupTypeOptionCellWriterImpl(Arc<dyn DatabaseViewOperation>); + +#[async_trait] +impl GroupTypeOptionCellOperation for GroupTypeOptionCellWriterImpl { + async fn get_cell(&self, _row_id: &RowId, _field_id: &str) -> FlowyResult<Option<Cell>> { + todo!() + } + + #[tracing::instrument(level = "trace", skip_all, err)] + async fn update_cell( + &self, + _view_id: &str, + _row_id: &RowId, + _field_id: &str, + _cell: Cell, + ) -> FlowyResult<()> { + todo!() + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/view_operation.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/view_operation.rs new file mode 100644 index 000000000000..99df59b512ff --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/view_operation.rs @@ -0,0 +1,125 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use collab_database::database::MutexDatabase; +use collab_database::fields::{Field, TypeOptionData}; +use collab_database::rows::{RowCell, RowDetail, RowId}; +use collab_database::views::{DatabaseLayout, DatabaseView, LayoutSetting}; +use tokio::sync::RwLock; + +use flowy_error::FlowyError; +use flowy_task::TaskDispatcher; +use lib_infra::future::{Fut, FutureResult}; + +use crate::entities::{FieldType, FieldVisibility}; +use crate::services::field::TypeOptionCellDataHandler; +use crate::services::field_settings::FieldSettings; +use crate::services::filter::Filter; +use crate::services::group::GroupSetting; +use crate::services::sort::Sort; + +/// Defines the operation that can be performed on a database view +pub trait DatabaseViewOperation: Send + Sync + 'static { + /// Get the database that the view belongs to + fn get_database(&self) -> Arc<MutexDatabase>; + + /// Get the view of the database with the view_id + fn get_view(&self, view_id: &str) -> Fut<Option<DatabaseView>>; + /// If the field_ids is None, then it will return all the field revisions + fn get_fields(&self, view_id: &str, field_ids: Option<Vec<String>>) -> Fut<Vec<Arc<Field>>>; + + /// Returns the field with the field_id + fn get_field(&self, field_id: &str) -> Option<Field>; + + fn create_field( + &self, + view_id: &str, + name: &str, + field_type: FieldType, + type_option_data: TypeOptionData, + ) -> Fut<Field>; + + fn update_field( + &self, + view_id: &str, + type_option_data: TypeOptionData, + old_field: Field, + ) -> FutureResult<(), FlowyError>; + + fn get_primary_field(&self) -> Fut<Option<Arc<Field>>>; + + /// Returns the index of the row with row_id + fn index_of_row(&self, view_id: &str, row_id: &RowId) -> Fut<Option<usize>>; + + /// Returns the `index` and `RowRevision` with row_id + fn get_row(&self, view_id: &str, row_id: &RowId) -> Fut<Option<(usize, Arc<RowDetail>)>>; + + /// Returns all the rows in the view + fn get_rows(&self, view_id: &str) -> Fut<Vec<Arc<RowDetail>>>; + + fn get_cells_for_field(&self, view_id: &str, field_id: &str) -> Fut<Vec<Arc<RowCell>>>; + + fn get_cell_in_row(&self, field_id: &str, row_id: &RowId) -> Fut<Arc<RowCell>>; + + /// Return the database layout type for the view with given view_id + /// The default layout type is [DatabaseLayout::Grid] + fn get_layout_for_view(&self, view_id: &str) -> DatabaseLayout; + + fn get_group_setting(&self, view_id: &str) -> Vec<GroupSetting>; + + fn insert_group_setting(&self, view_id: &str, setting: GroupSetting); + + fn get_sort(&self, view_id: &str, sort_id: &str) -> Option<Sort>; + + fn insert_sort(&self, view_id: &str, sort: Sort); + + fn remove_sort(&self, view_id: &str, sort_id: &str); + + fn get_all_sorts(&self, view_id: &str) -> Vec<Sort>; + + fn remove_all_sorts(&self, view_id: &str); + + fn get_all_filters(&self, view_id: &str) -> Vec<Arc<Filter>>; + + fn delete_filter(&self, view_id: &str, filter_id: &str); + + fn insert_filter(&self, view_id: &str, filter: Filter); + + fn get_filter(&self, view_id: &str, filter_id: &str) -> Option<Filter>; + + fn get_filter_by_field_id(&self, view_id: &str, field_id: &str) -> Option<Filter>; + + fn get_layout_setting(&self, view_id: &str, layout_ty: &DatabaseLayout) -> Option<LayoutSetting>; + + fn insert_layout_setting( + &self, + view_id: &str, + layout_ty: &DatabaseLayout, + layout_setting: LayoutSetting, + ); + + fn update_layout_type(&self, view_id: &str, layout_type: &DatabaseLayout); + + /// Returns a `TaskDispatcher` used to poll a `Task` + fn get_task_scheduler(&self) -> Arc<RwLock<TaskDispatcher>>; + + fn get_type_option_cell_handler( + &self, + field: &Field, + field_type: &FieldType, + ) -> Option<Box<dyn TypeOptionCellDataHandler>>; + + fn get_field_settings( + &self, + view_id: &str, + field_ids: &[String], + ) -> HashMap<String, FieldSettings>; + + fn update_field_settings( + &self, + view_id: &str, + field_id: &str, + visibility: Option<FieldVisibility>, + width: Option<i32>, + ); +} diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/view_sort.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/view_sort.rs index 6fec7d9ff9d6..6587d9ea0e97 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/view_sort.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/view_sort.rs @@ -8,14 +8,14 @@ use lib_infra::future::{to_fut, Fut}; use crate::services::cell::CellCache; use crate::services::database_view::{ - gen_handler_id, DatabaseViewChangedNotifier, DatabaseViewData, + gen_handler_id, DatabaseViewChangedNotifier, DatabaseViewOperation, }; use crate::services::filter::FilterController; use crate::services::sort::{Sort, SortController, SortDelegate, SortTaskHandler}; pub(crate) async fn make_sort_controller( view_id: &str, - delegate: Arc<dyn DatabaseViewData>, + delegate: Arc<dyn DatabaseViewOperation>, notifier: DatabaseViewChangedNotifier, filter_controller: Arc<FilterController>, cell_cache: CellCache, @@ -49,7 +49,7 @@ pub(crate) async fn make_sort_controller( } struct DatabaseViewSortDelegateImpl { - delegate: Arc<dyn DatabaseViewData>, + delegate: Arc<dyn DatabaseViewOperation>, filter_controller: Arc<FilterController>, } @@ -70,7 +70,7 @@ impl SortDelegate for DatabaseViewSortDelegateImpl { }) } - fn get_field(&self, field_id: &str) -> Fut<Option<Arc<Field>>> { + fn get_field(&self, field_id: &str) -> Option<Field> { self.delegate.get_field(field_id) } diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/views.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/views.rs index 509ff5849b47..8859422b9c11 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/views.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/views.rs @@ -2,47 +2,46 @@ use std::collections::HashMap; use std::sync::Arc; use collab_database::database::MutexDatabase; -use collab_database::fields::Field; use collab_database::rows::{RowDetail, RowId}; use nanoid::nanoid; use tokio::sync::{broadcast, RwLock}; -use flowy_error::FlowyResult; +use flowy_error::{FlowyError, FlowyResult}; use lib_infra::future::Fut; use crate::services::cell::CellCache; use crate::services::database::DatabaseRowEvent; -use crate::services::database_view::{DatabaseViewData, DatabaseViewEditor}; +use crate::services::database_view::{DatabaseViewEditor, DatabaseViewOperation}; use crate::services::group::RowChangeset; pub type RowEventSender = broadcast::Sender<DatabaseRowEvent>; pub type RowEventReceiver = broadcast::Receiver<DatabaseRowEvent>; - +pub type EditorByViewId = HashMap<String, Arc<DatabaseViewEditor>>; pub struct DatabaseViews { #[allow(dead_code)] database: Arc<MutexDatabase>, cell_cache: CellCache, - database_view_data: Arc<dyn DatabaseViewData>, - editor_map: Arc<RwLock<HashMap<String, Arc<DatabaseViewEditor>>>>, + view_operation: Arc<dyn DatabaseViewOperation>, + editor_by_view_id: Arc<RwLock<EditorByViewId>>, } impl DatabaseViews { pub async fn new( database: Arc<MutexDatabase>, cell_cache: CellCache, - database_view_data: Arc<dyn DatabaseViewData>, + view_operation: Arc<dyn DatabaseViewOperation>, + editor_by_view_id: Arc<RwLock<EditorByViewId>>, ) -> FlowyResult<Self> { - let editor_map = Arc::new(RwLock::new(HashMap::default())); Ok(Self { database, - database_view_data, + view_operation, cell_cache, - editor_map, + editor_by_view_id, }) } pub async fn close_view(&self, view_id: &str) -> bool { - let mut editor_map = self.editor_map.write().await; + let mut editor_map = self.editor_by_view_id.write().await; if let Some(view) = editor_map.remove(view_id) { view.close().await; } @@ -50,7 +49,13 @@ impl DatabaseViews { } pub async fn editors(&self) -> Vec<Arc<DatabaseViewEditor>> { - self.editor_map.read().await.values().cloned().collect() + self + .editor_by_view_id + .read() + .await + .values() + .cloned() + .collect() } /// It may generate a RowChangeset when the Row was moved from one group to another. @@ -77,43 +82,22 @@ impl DatabaseViews { Ok(()) } - /// Notifies the view's field type-option data is changed - /// For the moment, only the groups will be generated after the type-option data changed. A - /// [Field] has a property named type_options contains a list of type-option data. - /// # Arguments - /// - /// * `field_id`: the id of the field in current view - /// - #[tracing::instrument(level = "debug", skip(self, old_field), err)] - pub async fn did_update_field_type_option( - &self, - view_id: &str, - field_id: &str, - old_field: &Field, - ) -> FlowyResult<()> { - let view_editor = self.get_view_editor(view_id).await?; - // If the id of the grouping field is equal to the updated field's id, then we need to - // update the group setting - if view_editor.is_grouping_field(field_id).await { - view_editor.v_grouping_by_field(field_id).await?; - } - view_editor - .v_did_update_field_type_option(field_id, old_field) - .await?; - Ok(()) - } - pub async fn get_view_editor(&self, view_id: &str) -> FlowyResult<Arc<DatabaseViewEditor>> { debug_assert!(!view_id.is_empty()); - if let Some(editor) = self.editor_map.read().await.get(view_id) { + if let Some(editor) = self.editor_by_view_id.read().await.get(view_id) { return Ok(editor.clone()); } - let mut editor_map = self.editor_map.write().await; + let mut editor_map = self.editor_by_view_id.try_write().map_err(|err| { + FlowyError::internal().with_context(format!( + "fail to acquire the lock of editor_by_view_id: {}", + err + )) + })?; let editor = Arc::new( DatabaseViewEditor::new( view_id.to_owned(), - self.database_view_data.clone(), + self.view_operation.clone(), self.cell_cache.clone(), ) .await?, diff --git a/frontend/rust-lib/flowy-database2/src/services/field/field_builder.rs b/frontend/rust-lib/flowy-database2/src/services/field/field_builder.rs index 95347214afc2..7a2009de9179 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/field_builder.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/field_builder.rs @@ -15,7 +15,7 @@ impl FieldBuilder { field_type.clone().into(), false, ); - field.width = field_type.default_cell_width() as i64; + field.width = 150; field .type_options .insert(field_type.to_string(), type_option_data.into()); diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_ids.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_ids.rs index 1914414b9168..55fe2635bcbf 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_ids.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_ids.rs @@ -24,7 +24,6 @@ impl SelectOptionIds { pub fn into_inner(self) -> Vec<String> { self.0 } - pub fn to_cell_data(&self, field_type: FieldType) -> Cell { new_cell_builder(field_type) .insert_str_value(CELL_DATA, self.to_string()) diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_type_option.rs index 666ff41792ea..26af4036d96f 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_type_option.rs @@ -25,15 +25,27 @@ pub trait SelectTypeOptionSharedAction: Send + Sync { /// If the option already exists, it will be updated. /// If the option does not exist, it will be inserted at the beginning. fn insert_option(&mut self, new_option: SelectOption) { + self.insert_option_at_index(new_option, None); + } + + fn insert_option_at_index(&mut self, new_option: SelectOption, new_index: Option<usize>) { let options = self.mut_options(); + let safe_new_index = new_index.map(|index| { + if index > options.len() { + options.len() + } else { + index + } + }); + if let Some(index) = options .iter() .position(|option| option.id == new_option.id || option.name == new_option.name) { options.remove(index); - options.insert(index, new_option); + options.insert(safe_new_index.unwrap_or(index), new_option); } else { - options.insert(0, new_option); + options.insert(safe_new_index.unwrap_or(0), new_option); } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs index 4e06f7b1aff3..7a5429076f59 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs @@ -33,7 +33,15 @@ pub trait TypeOption { /// /// Uses `StrCellData` for any `TypeOption` if their cell data is pure `String`. /// - type CellData: TypeOptionCellData + ToString + Default + Send + Sync + Clone + Debug + 'static; + type CellData: for<'a> From<&'a Cell> + + TypeOptionCellData + + ToString + + Default + + Send + + Sync + + Clone + + Debug + + 'static; /// Represents as the corresponding field type cell changeset. /// The changeset must implements the `FromCellChangesetString` and the `ToCellChangesetString` trait. diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs index 52b7db87519a..c07f8ff79f63 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs @@ -113,16 +113,21 @@ where + Sync + 'static, { + pub fn into_boxed(self) -> Box<dyn TypeOptionCellDataHandler> { + Box::new(self) as Box<dyn TypeOptionCellDataHandler> + } + pub fn new_with_boxed( inner: T, cell_filter_cache: Option<CellFilterCache>, cell_data_cache: Option<CellCache>, ) -> Box<dyn TypeOptionCellDataHandler> { - Box::new(Self { + Self { inner, cell_data_cache, cell_filter_cache, - }) as Box<dyn TypeOptionCellDataHandler> + } + .into_boxed() } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field_settings/entities.rs b/frontend/rust-lib/flowy-database2/src/services/field_settings/entities.rs index ab3dd8d82428..674864afca14 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field_settings/entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field_settings/entities.rs @@ -1,32 +1,42 @@ -use anyhow::bail; use collab::core::any_map::AnyMapExtension; -use collab_database::views::{FieldSettingsMap, FieldSettingsMapBuilder}; +use collab_database::views::{DatabaseLayout, FieldSettingsMap, FieldSettingsMapBuilder}; use crate::entities::FieldVisibility; +use crate::services::field_settings::default_field_visibility; /// Stores the field settings for a single field #[derive(Debug, Clone)] pub struct FieldSettings { pub field_id: String, pub visibility: FieldVisibility, + pub width: i32, } pub const VISIBILITY: &str = "visibility"; +pub const WIDTH: &str = "width"; + +pub const DEFAULT_WIDTH: i32 = 150; impl FieldSettings { - pub fn try_from_anymap( - field_id: String, - field_settings: FieldSettingsMap, - ) -> Result<Self, anyhow::Error> { - let visibility = match field_settings.get_i64_value(VISIBILITY) { - Some(visbility) => visbility.into(), - _ => bail!("Invalid field settings data"), - }; - - Ok(Self { - field_id, + pub fn from_anymap( + field_id: &str, + layout_type: DatabaseLayout, + field_settings: &FieldSettingsMap, + ) -> Self { + let visibility = field_settings + .get_i64_value(VISIBILITY) + .map(Into::into) + .unwrap_or_else(|| default_field_visibility(layout_type)); + let width = field_settings + .get_i64_value(WIDTH) + .map(|value| value as i32) + .unwrap_or(DEFAULT_WIDTH); + + Self { + field_id: field_id.to_string(), visibility, - }) + width, + } } } @@ -34,14 +44,16 @@ impl From<FieldSettings> for FieldSettingsMap { fn from(field_settings: FieldSettings) -> Self { FieldSettingsMapBuilder::new() .insert_i64_value(VISIBILITY, field_settings.visibility.into()) + .insert_i64_value(WIDTH, field_settings.width as i64) .build() } } /// Contains the changeset to a field's settings. -/// A `Some` value for constitutes a change in that particular setting +/// A `Some` value constitutes a change in that particular setting pub struct FieldSettingsChangesetParams { pub view_id: String, pub field_id: String, pub visibility: Option<FieldVisibility>, + pub width: Option<i32>, } diff --git a/frontend/rust-lib/flowy-database2/src/services/field_settings/field_settings_builder.rs b/frontend/rust-lib/flowy-database2/src/services/field_settings/field_settings_builder.rs index 01c0a6b875ff..4b4b49412b5f 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field_settings/field_settings_builder.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field_settings/field_settings_builder.rs @@ -1,7 +1,5 @@ use std::collections::HashMap; -use std::sync::Arc; -use collab_database::database::MutexDatabase; use collab_database::fields::Field; use collab_database::views::{ DatabaseLayout, FieldSettingsByFieldIdMap, FieldSettingsMap, FieldSettingsMapBuilder, @@ -9,12 +7,11 @@ use collab_database::views::{ use strum::IntoEnumIterator; use crate::entities::FieldVisibility; - use crate::services::field_settings::{FieldSettings, VISIBILITY}; /// Helper struct to create a new field setting pub struct FieldSettingsBuilder { - field_settings: FieldSettings, + inner: FieldSettings, } impl FieldSettingsBuilder { @@ -22,57 +19,49 @@ impl FieldSettingsBuilder { let field_settings = FieldSettings { field_id: field_id.to_string(), visibility: FieldVisibility::AlwaysShown, + width: 150, }; - Self { field_settings } + Self { + inner: field_settings, + } } - pub fn field_id(mut self, field_id: &str) -> Self { - self.field_settings.field_id = field_id.to_string(); + pub fn visibility(mut self, visibility: FieldVisibility) -> Self { + self.inner.visibility = visibility; self } - pub fn visibility(mut self, visibility: FieldVisibility) -> Self { - self.field_settings.visibility = visibility; + pub fn width(mut self, width: i32) -> Self { + self.inner.width = width; self } pub fn build(self) -> FieldSettings { - self.field_settings + self.inner } } -pub struct DatabaseFieldSettingsMapBuilder { - pub fields: Vec<Field>, - pub database_layout: DatabaseLayout, -} - -impl DatabaseFieldSettingsMapBuilder { - pub fn new(fields: Vec<Field>, database_layout: DatabaseLayout) -> Self { - Self { - fields, - database_layout, - } - } - - pub fn from_database(database: Arc<MutexDatabase>, database_layout: DatabaseLayout) -> Self { - let fields = database.lock().get_fields(None); - Self { - fields, - database_layout, - } +#[inline] +pub fn default_field_visibility(layout_type: DatabaseLayout) -> FieldVisibility { + match layout_type { + DatabaseLayout::Grid => FieldVisibility::AlwaysShown, + DatabaseLayout::Board => FieldVisibility::HideWhenEmpty, + DatabaseLayout::Calendar => FieldVisibility::HideWhenEmpty, } +} - pub fn build(self) -> FieldSettingsByFieldIdMap { - self - .fields - .into_iter() - .map(|field| { - let field_settings = field_settings_for_field(self.database_layout, &field); - (field.id, field_settings) - }) - .collect::<HashMap<String, FieldSettingsMap>>() - .into() - } +pub fn default_field_settings_for_fields( + fields: &[Field], + layout_type: DatabaseLayout, +) -> FieldSettingsByFieldIdMap { + fields + .iter() + .map(|field| { + let field_settings = field_settings_for_field(layout_type, field); + (field.id.clone(), field_settings) + }) + .collect::<HashMap<_, _>>() + .into() } pub fn field_settings_for_field( @@ -82,11 +71,7 @@ pub fn field_settings_for_field( let visibility = if field.is_primary { FieldVisibility::AlwaysShown } else { - match database_layout { - DatabaseLayout::Grid => FieldVisibility::AlwaysShown, - DatabaseLayout::Board => FieldVisibility::HideWhenEmpty, - DatabaseLayout::Calendar => FieldVisibility::HideWhenEmpty, - } + default_field_visibility(database_layout) }; FieldSettingsBuilder::new(&field.id) @@ -98,11 +83,7 @@ pub fn field_settings_for_field( pub fn default_field_settings_by_layout_map() -> HashMap<DatabaseLayout, FieldSettingsMap> { let mut map = HashMap::new(); for layout_ty in DatabaseLayout::iter() { - let visibility = match layout_ty { - DatabaseLayout::Grid => FieldVisibility::AlwaysShown, - DatabaseLayout::Board => FieldVisibility::HideWhenEmpty, - DatabaseLayout::Calendar => FieldVisibility::HideWhenEmpty, - }; + let visibility = default_field_visibility(layout_ty); let field_settings = FieldSettingsMapBuilder::new() .insert_i64_value(VISIBILITY, visibility.into()) .build(); diff --git a/frontend/rust-lib/flowy-database2/src/services/filter/controller.rs b/frontend/rust-lib/flowy-database2/src/services/filter/controller.rs index 6d25e24c0c76..ab6a56c45ace 100644 --- a/frontend/rust-lib/flowy-database2/src/services/filter/controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/filter/controller.rs @@ -21,7 +21,7 @@ use crate::services::filter::{Filter, FilterChangeset, FilterResult, FilterResul pub trait FilterDelegate: Send + Sync + 'static { fn get_filter(&self, view_id: &str, filter_id: &str) -> Fut<Option<Arc<Filter>>>; - fn get_field(&self, field_id: &str) -> Fut<Option<Arc<Field>>>; + fn get_field(&self, field_id: &str) -> Option<Field>; fn get_fields(&self, view_id: &str, field_ids: Option<Vec<String>>) -> Fut<Vec<Arc<Field>>>; fn get_rows(&self, view_id: &str) -> Fut<Vec<Arc<RowDetail>>>; fn get_row(&self, view_id: &str, rows_id: &RowId) -> Fut<Option<(usize, Arc<RowDetail>)>>; diff --git a/frontend/rust-lib/flowy-database2/src/services/group/action.rs b/frontend/rust-lib/flowy-database2/src/services/group/action.rs index 2163e840a1da..9af51f167bfe 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/action.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/action.rs @@ -1,20 +1,19 @@ -use collab_database::fields::Field; +use async_trait::async_trait; +use collab_database::fields::{Field, TypeOptionData}; use collab_database::rows::{Cell, Row, RowDetail}; use flowy_error::FlowyResult; use crate::entities::{GroupChangesPB, GroupPB, GroupRowsNotificationPB, InsertedGroupPB}; -use crate::services::cell::DecodedCellData; -use crate::services::group::controller::MoveGroupRowContext; -use crate::services::group::entities::GroupSetting; -use crate::services::group::{GroupChangesets, GroupData, GroupSettingChangeset}; +use crate::services::field::TypeOption; +use crate::services::group::{GroupChangesets, GroupData, MoveGroupRowContext}; /// Using polymorphism to provides the customs action for different group controller. /// /// For example, the `CheckboxGroupController` implements this trait to provide custom behavior. /// pub trait GroupCustomize: Send + Sync { - type CellData: DecodedCellData; + type GroupTypeOption: TypeOption; /// Returns the a value of the cell if the cell data is not exist. /// The default value is `None` /// @@ -26,13 +25,17 @@ pub trait GroupCustomize: Send + Sync { } /// Returns a bool value to determine whether the group should contain this cell or not. - fn can_group(&self, content: &str, cell_data: &Self::CellData) -> bool; + fn can_group( + &self, + content: &str, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellData, + ) -> bool; fn create_or_delete_group_when_cell_changed( &mut self, _row_detail: &RowDetail, - _old_cell_data: Option<&Self::CellData>, - _cell_data: &Self::CellData, + _old_cell_data: Option<&<Self::GroupTypeOption as TypeOption>::CellProtobufType>, + _cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, ) -> FlowyResult<(Option<InsertedGroupPB>, Option<GroupPB>)> { Ok((None, None)) } @@ -43,16 +46,20 @@ pub trait GroupCustomize: Send + Sync { fn add_or_remove_row_when_cell_changed( &mut self, row_detail: &RowDetail, - cell_data: &Self::CellData, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, ) -> Vec<GroupRowsNotificationPB>; /// Deletes the row from the group - fn delete_row(&mut self, row: &Row, cell_data: &Self::CellData) -> Vec<GroupRowsNotificationPB>; + fn delete_row( + &mut self, + row: &Row, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellData, + ) -> (Option<GroupPB>, Vec<GroupRowsNotificationPB>); /// Move row from one group to another fn move_row( &mut self, - cell_data: &Self::CellData, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, context: MoveGroupRowContext, ) -> Vec<GroupRowsNotificationPB>; @@ -60,30 +67,75 @@ pub trait GroupCustomize: Send + Sync { fn delete_group_when_move_row( &mut self, _row: &Row, - _cell_data: &Self::CellData, + _cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, ) -> Option<GroupPB> { None } + + fn generate_new_group( + &mut self, + _name: String, + ) -> FlowyResult<(Option<TypeOptionData>, Option<InsertedGroupPB>)> { + Ok((None, None)) + } } /// Defines the shared actions any group controller can perform. +#[async_trait] pub trait GroupControllerOperation: Send + Sync { - /// The field that is used for grouping the rows + /// Returns the id of field that is being used to group the rows fn field_id(&self) -> &str; - /// Returns number of groups the current field has - fn groups(&self) -> Vec<&GroupData>; + /// Returns all of the groups currently managed by the controller + fn get_all_groups(&self) -> Vec<&GroupData>; - /// Returns the index and the group data with group_id + /// Returns the index and the group data with the given group id if it exists. + /// + /// * `group_id` - A string slice that is used to match the group fn get_group(&self, group_id: &str) -> Option<(usize, GroupData)>; - /// Separates the rows into different groups + /// Sort the rows into the different groups. + /// + /// * `rows`: rows to be inserted + /// * `field`: reference to the field being sorted (currently unused) fn fill_groups(&mut self, rows: &[&RowDetail], field: &Field) -> FlowyResult<()>; - /// Remove the group with from_group_id and insert it to the index with to_group_id + /// Create a new group, currently only supports single and multi-select. + /// + /// Returns a new type option data for the grouping field if it's altered. + /// + /// * `name`: name of the new group + fn create_group( + &mut self, + name: String, + ) -> FlowyResult<(Option<TypeOptionData>, Option<InsertedGroupPB>)>; + + /// Reorders the group in the group controller. + /// + /// * `from_group_id`: id of the group being moved + /// * `to_group_id`: id of the group whose index is the one at which the + /// reordered group will be placed fn move_group(&mut self, from_group_id: &str, to_group_id: &str) -> FlowyResult<()>; - /// Insert/Remove the row to the group if the corresponding cell data is changed + /// Adds a newly-created row to one or more suitable groups. + /// + /// Returns a changeset payload to be sent as a notification. + /// + /// * `row_detail`: the newly-created row + fn did_create_row( + &mut self, + row_detail: &RowDetail, + index: usize, + ) -> Vec<GroupRowsNotificationPB>; + + /// Called after a row's cell data is changed, this moves the row to the + /// correct group. It may also insert a new group and/or remove an old group. + /// + /// Returns the inserted and removed groups if necessary for notification. + /// + /// * `old_row_detail`: + /// * `row_detail`: + /// * `field`: fn did_update_group_row( &mut self, old_row_detail: &Option<RowDetail>, @@ -91,25 +143,32 @@ pub trait GroupControllerOperation: Send + Sync { field: &Field, ) -> FlowyResult<DidUpdateGroupRowResult>; - /// Remove the row from the group if the row gets deleted - fn did_delete_delete_row( - &mut self, - row: &Row, - field: &Field, - ) -> FlowyResult<DidMoveGroupRowResult>; + /// Called after the row is deleted, this removes the row from the group. + /// A group could be deleted as a result. + /// + /// Returns a the removed group when this occurs. + fn did_delete_row(&mut self, row: &Row) -> FlowyResult<DidMoveGroupRowResult>; - /// Move the row from one group to another group + /// Reorders a row within the current group or move the row to another group. + /// + /// * `context`: information about the row being moved and its destination fn move_group_row(&mut self, context: MoveGroupRowContext) -> FlowyResult<DidMoveGroupRowResult>; - /// Update the group if the corresponding field is changed + /// Updates the groups after a field change. (currently never does anything) + /// + /// * `field`: new changeset fn did_update_group_field(&mut self, field: &Field) -> FlowyResult<Option<GroupChangesPB>>; - fn apply_group_setting_changeset(&mut self, changeset: GroupChangesets) -> FlowyResult<()>; - - fn apply_group_configuration_setting_changeset( + /// Updates the name and/or visibility of groups. + /// + /// Returns a non-empty `TypeOptionData` when the changes require a change + /// in the field type option data. + /// + /// * `changesets`: list of changesets to be made to one or more groups + async fn apply_group_changeset( &mut self, - changeset: GroupSettingChangeset, - ) -> FlowyResult<Option<GroupSetting>>; + changesets: &GroupChangesets, + ) -> FlowyResult<(Vec<GroupPB>, TypeOptionData)>; } #[derive(Debug)] diff --git a/frontend/rust-lib/flowy-database2/src/services/group/configuration.rs b/frontend/rust-lib/flowy-database2/src/services/group/configuration.rs index ed44bf0ed322..3b387940bdc8 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/configuration.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/configuration.rs @@ -3,19 +3,22 @@ use std::fmt::Formatter; use std::marker::PhantomData; use std::sync::Arc; +use async_trait::async_trait; use collab_database::fields::Field; +use collab_database::rows::{Cell, RowId}; use indexmap::IndexMap; use serde::de::DeserializeOwned; use serde::Serialize; +use tracing::event; use flowy_error::{FlowyError, FlowyResult}; +use lib_dispatch::prelude::af_spawn; use lib_infra::future::Fut; use crate::entities::{GroupChangesPB, GroupPB, InsertedGroupPB}; use crate::services::field::RowSingleCellData; use crate::services::group::{ default_group_setting, GeneratedGroups, Group, GroupChangeset, GroupData, GroupSetting, - GroupSettingChangeset, }; pub trait GroupSettingReader: Send + Sync + 'static { @@ -27,6 +30,18 @@ pub trait GroupSettingWriter: Send + Sync + 'static { fn save_configuration(&self, view_id: &str, group_setting: GroupSetting) -> Fut<FlowyResult<()>>; } +#[async_trait] +pub trait GroupTypeOptionCellOperation: Send + Sync + 'static { + async fn get_cell(&self, row_id: &RowId, field_id: &str) -> FlowyResult<Option<Cell>>; + async fn update_cell( + &self, + view_id: &str, + row_id: &RowId, + field_id: &str, + cell: Cell, + ) -> FlowyResult<()>; +} + impl<T> std::fmt::Display for GroupContext<T> { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { self.group_by_id.iter().for_each(|(_, group)| { @@ -64,7 +79,6 @@ pub struct GroupContext<C> { /// A reader that implement the [GroupSettingReader] trait /// - #[allow(dead_code)] reader: Arc<dyn GroupSettingReader>, /// A writer that implement the [GroupSettingWriter] trait is used to save the @@ -84,6 +98,7 @@ where reader: Arc<dyn GroupSettingReader>, writer: Arc<dyn GroupSettingWriter>, ) -> FlowyResult<Self> { + event!(tracing::Level::TRACE, "GroupContext::new"); let setting = match reader.get_group_setting(&view_id).await { None => { let default_configuration = default_group_setting(&field); @@ -109,6 +124,7 @@ where /// Returns the no `status` group /// /// We take the `id` of the `field` as the no status group id + #[allow(dead_code)] pub(crate) fn get_no_status_group(&self) -> Option<&GroupData> { self.group_by_id.get(&self.field.id) } @@ -157,6 +173,7 @@ where self.field.id.clone(), group.name.clone(), group.id.clone(), + group.visible, ); self.group_by_id.insert(group.id.clone(), group_data); let (index, group_data) = self.get_group(&group.id).unwrap(); @@ -234,7 +251,7 @@ where /// /// # Arguments /// - /// * `generated_group_configs`: the generated groups contains a list of [GeneratedGroupConfig]. + /// * `generated_groups`: the generated groups contains a list of [GeneratedGroupConfig]. /// /// Each [FieldType] can implement the [GroupGenerator] trait in order to generate different /// groups. For example, the FieldType::Checkbox has the [CheckboxGroupGenerator] that implements @@ -322,7 +339,13 @@ where .get(&group.id) .cloned() .unwrap_or_else(|| "".to_owned()); - let group = GroupData::new(group.id, self.field.id.clone(), group.name, filter_content); + let group = GroupData::new( + group.id, + self.field.id.clone(), + group.name, + filter_content, + group.visible, + ); self.group_by_id.insert(group.id.clone(), group); }); @@ -335,6 +358,7 @@ where self.field.id.clone(), group_rev.name, filter_content.clone(), + group_rev.visible, ); Some(GroupPB::from(group)) }) @@ -356,7 +380,7 @@ where } } - pub(crate) fn update_group(&mut self, group_changeset: GroupChangeset) -> FlowyResult<()> { + pub(crate) fn update_group(&mut self, group_changeset: &GroupChangeset) -> FlowyResult<()> { let update_group = self.mut_group(&group_changeset.group_id, |group| { if let Some(visible) = group_changeset.visible { group.visible = visible; @@ -375,20 +399,6 @@ where Ok(()) } - pub(crate) fn update_configuration( - &mut self, - changeset: GroupSettingChangeset, - ) -> FlowyResult<Option<GroupSetting>> { - self.mut_configuration(|configuration| match changeset.hide_ungrouped { - Some(value) if value != configuration.hide_ungrouped => { - configuration.hide_ungrouped = value; - true - }, - _ => false, - })?; - Ok(Some(GroupSetting::clone(&self.setting))) - } - pub(crate) async fn get_all_cells(&self) -> Vec<RowSingleCellData> { self .reader @@ -415,11 +425,9 @@ where let configuration = (*self.setting).clone(); let writer = self.writer.clone(); let view_id = self.view_id.clone(); - tokio::spawn(async move { + af_spawn(async move { match writer.save_configuration(&view_id, configuration).await { - Ok(_) => { - tracing::trace!("SUCCESSFULLY SAVED CONFIGURATION"); // TODO(richard): remove this - }, + Ok(_) => {}, Err(e) => { tracing::error!("Save group configuration failed: {}", e); }, @@ -462,14 +470,20 @@ fn merge_groups( ) -> MergeGroupResult { let mut merge_result = MergeGroupResult::new(); // group_map is a helper map is used to filter out the new groups. - let mut new_group_map: IndexMap<String, Group> = IndexMap::new(); - new_groups.into_iter().for_each(|group_rev| { - new_group_map.insert(group_rev.id.clone(), group_rev); - }); + let mut new_group_map: IndexMap<String, Group> = new_groups + .into_iter() + .map(|group| (group.id.clone(), group)) + .collect(); // The group is ordered in old groups. Add them before adding the new groups for old in old_groups { - if let Some(new) = new_group_map.remove(&old.id) { + if let Some(index) = new_group_map.get_index_of(&old.id) { + let right = new_group_map.split_off(index); + merge_result.all_groups.extend(new_group_map.into_values()); + new_group_map = right; + } + + if let Some(new) = new_group_map.shift_remove(&old.id) { merge_result.all_groups.push(new.clone()); } else { merge_result.deleted_groups.push(old); diff --git a/frontend/rust-lib/flowy-database2/src/services/group/controller.rs b/frontend/rust-lib/flowy-database2/src/services/group/controller.rs index 94af70149810..fd049759ac30 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/controller.rs @@ -1,24 +1,27 @@ -use std::collections::HashMap; use std::marker::PhantomData; use std::sync::Arc; +use async_trait::async_trait; use collab_database::fields::{Field, TypeOptionData}; -use collab_database::rows::{Cell, Cells, Row, RowDetail, RowId}; +use collab_database::rows::{Cells, Row, RowDetail}; +use futures::executor::block_on; use serde::de::DeserializeOwned; use serde::Serialize; use flowy_error::FlowyResult; use crate::entities::{ - FieldType, GroupChangesPB, GroupRowsNotificationPB, InsertedRowPB, RowMetaPB, + FieldType, GroupChangesPB, GroupPB, GroupRowsNotificationPB, InsertedGroupPB, InsertedRowPB, + RowMetaPB, }; -use crate::services::cell::{get_cell_protobuf, CellProtobufBlobParser, DecodedCellData}; +use crate::services::cell::{get_cell_protobuf, CellProtobufBlobParser}; +use crate::services::field::{default_type_option_data_from_type, TypeOption, TypeOptionCellData}; use crate::services::group::action::{ DidMoveGroupRowResult, DidUpdateGroupRowResult, GroupControllerOperation, GroupCustomize, }; use crate::services::group::configuration::GroupContext; -use crate::services::group::entities::{GroupData, GroupSetting}; -use crate::services::group::{Group, GroupChangesets, GroupSettingChangeset}; +use crate::services::group::entities::GroupData; +use crate::services::group::{GroupChangeset, GroupChangesets, GroupsBuilder, MoveGroupRowContext}; // use collab_database::views::Group; @@ -32,72 +35,22 @@ use crate::services::group::{Group, GroupChangesets, GroupSettingChangeset}; /// pub trait GroupController: GroupControllerOperation + Send + Sync { /// Called when the type option of the [Field] was updated. - fn did_update_field_type_option(&mut self, field: &Arc<Field>); + fn did_update_field_type_option(&mut self, field: &Field); /// Called before the row was created. fn will_create_row(&mut self, cells: &mut Cells, field: &Field, group_id: &str); - - /// Called after the row was created. - fn did_create_row(&mut self, row_detail: &RowDetail, group_id: &str); - - /// Update group name handler - fn update_group_name(&mut self, _group_id: &str, _group_name: &str) -> Option<TypeOptionData> { - None - } -} - -/// The [GroupsBuilder] trait is used to generate the groups for different [FieldType] -pub trait GroupsBuilder { - type Context; - type TypeOptionType; - - fn build( - field: &Field, - context: &Self::Context, - type_option: &Option<Self::TypeOptionType>, - ) -> GeneratedGroups; -} - -pub struct GeneratedGroups { - pub no_status_group: Option<Group>, - pub group_configs: Vec<GeneratedGroupConfig>, -} - -pub struct GeneratedGroupConfig { - pub group: Group, - pub filter_content: String, -} - -pub struct MoveGroupRowContext<'a> { - pub row_detail: &'a RowDetail, - pub row_changeset: &'a mut RowChangeset, - pub field: &'a Field, - pub to_group_id: &'a str, - pub to_row_id: Option<RowId>, } -#[derive(Debug, Clone)] -pub struct RowChangeset { - pub row_id: RowId, - pub height: Option<i32>, - pub visibility: Option<bool>, - // Contains the key/value changes represents as the update of the cells. For example, - // if there is one cell was changed, then the `cell_by_field_id` will only have one key/value. - pub cell_by_field_id: HashMap<String, Cell>, -} - -impl RowChangeset { - pub fn new(row_id: RowId) -> Self { - Self { - row_id, - height: None, - visibility: None, - cell_by_field_id: Default::default(), - } - } - - pub fn is_empty(&self) -> bool { - self.height.is_none() && self.visibility.is_none() && self.cell_by_field_id.is_empty() +#[async_trait] +pub trait GroupOperationInterceptor { + type GroupTypeOption: TypeOption; + async fn type_option_from_group_changeset( + &self, + _changeset: &GroupChangeset, + _type_option: &Self::GroupTypeOption, + _view_id: &str, + ) -> Option<TypeOptionData> { + None } } @@ -105,35 +58,43 @@ impl RowChangeset { /// T: the type-option data deserializer that impl [TypeOptionDataDeserializer] /// G: the group generator, [GroupsBuilder] /// P: the parser that impl [CellProtobufBlobParser] for the CellBytes -pub struct BaseGroupController<C, T, G, P> { +pub struct BaseGroupController<C, T, G, P, I> { pub grouping_field_id: String, - pub type_option: Option<T>, + pub type_option: T, pub context: GroupContext<C>, - group_action_phantom: PhantomData<G>, + group_builder_phantom: PhantomData<G>, cell_parser_phantom: PhantomData<P>, + pub operation_interceptor: I, } -impl<C, T, G, P> BaseGroupController<C, T, G, P> +impl<C, T, G, P, I> BaseGroupController<C, T, G, P, I> where C: Serialize + DeserializeOwned, - T: From<TypeOptionData>, - G: GroupsBuilder<Context = GroupContext<C>, TypeOptionType = T>, + T: TypeOption + From<TypeOptionData> + Send + Sync, + G: GroupsBuilder<Context = GroupContext<C>, GroupTypeOption = T>, + I: GroupOperationInterceptor<GroupTypeOption = T> + Send + Sync, { pub async fn new( grouping_field: &Arc<Field>, mut configuration: GroupContext<C>, + operation_interceptor: I, ) -> FlowyResult<Self> { let field_type = FieldType::from(grouping_field.field_type); - let type_option = grouping_field.get_type_option::<T>(field_type); - let generated_groups = G::build(grouping_field, &configuration, &type_option); + let type_option = grouping_field + .get_type_option::<T>(&field_type) + .unwrap_or_else(|| T::from(default_type_option_data_from_type(&field_type))); + + // TODO(nathan): remove block_on + let generated_groups = block_on(G::build(grouping_field, &configuration, &type_option)); let _ = configuration.init_groups(generated_groups)?; Ok(Self { grouping_field_id: grouping_field.id.clone(), type_option, context: configuration, - group_action_phantom: PhantomData, + group_builder_phantom: PhantomData, cell_parser_phantom: PhantomData, + operation_interceptor, }) } @@ -209,20 +170,21 @@ where } } -impl<C, T, G, P> GroupControllerOperation for BaseGroupController<C, T, G, P> +#[async_trait] +impl<C, T, G, P, I> GroupControllerOperation for BaseGroupController<C, T, G, P, I> where - P: CellProtobufBlobParser, - C: Serialize + DeserializeOwned, - T: From<TypeOptionData>, - G: GroupsBuilder<Context = GroupContext<C>, TypeOptionType = T>, - - Self: GroupCustomize<CellData = P::Object>, + P: CellProtobufBlobParser<Object = <T as TypeOption>::CellProtobufType>, + C: Serialize + DeserializeOwned + Sync + Send, + T: TypeOption + From<TypeOptionData> + Send + Sync, + G: GroupsBuilder<Context = GroupContext<C>, GroupTypeOption = T>, + I: GroupOperationInterceptor<GroupTypeOption = T> + Send + Sync, + Self: GroupCustomize<GroupTypeOption = T>, { fn field_id(&self) -> &str { &self.grouping_field_id } - fn groups(&self) -> Vec<&GroupData> { + fn get_all_groups(&self) -> Vec<&GroupData> { self.context.groups() } @@ -232,7 +194,7 @@ where } #[tracing::instrument(level = "trace", skip_all, fields(row_count=%rows.len(), group_result))] - fn fill_groups(&mut self, rows: &[&RowDetail], field: &Field) -> FlowyResult<()> { + fn fill_groups(&mut self, rows: &[&RowDetail], _field: &Field) -> FlowyResult<()> { for row_detail in rows { let cell = match row_detail.row.cells.get(&self.grouping_field_id) { None => self.placeholder_cell(), @@ -241,8 +203,7 @@ where if let Some(cell) = cell { let mut grouped_rows: Vec<GroupedRow> = vec![]; - let cell_bytes = get_cell_protobuf(&cell, field, None); - let cell_data = cell_bytes.parser::<P>()?; + let cell_data = <T as TypeOption>::CellData::from(&cell); for group in self.context.groups() { if self.can_group(&group.filter_content, &cell_data) { grouped_rows.push(GroupedRow { @@ -272,10 +233,70 @@ where Ok(()) } + fn create_group( + &mut self, + name: String, + ) -> FlowyResult<(Option<TypeOptionData>, Option<InsertedGroupPB>)> { + self.generate_new_group(name) + } + fn move_group(&mut self, from_group_id: &str, to_group_id: &str) -> FlowyResult<()> { self.context.move_group(from_group_id, to_group_id) } + fn did_create_row( + &mut self, + row_detail: &RowDetail, + index: usize, + ) -> Vec<GroupRowsNotificationPB> { + let cell = match row_detail.row.cells.get(&self.grouping_field_id) { + None => self.placeholder_cell(), + Some(cell) => Some(cell.clone()), + }; + + let mut changesets: Vec<GroupRowsNotificationPB> = vec![]; + if let Some(cell) = cell { + let cell_data = <T as TypeOption>::CellData::from(&cell); + + let mut suitable_group_ids = vec![]; + + for group in self.get_all_groups() { + if self.can_group(&group.filter_content, &cell_data) { + suitable_group_ids.push(group.id.clone()); + let changeset = GroupRowsNotificationPB::insert( + group.id.clone(), + vec![InsertedRowPB { + row_meta: row_detail.into(), + index: Some(index as i32), + is_new: true, + }], + ); + changesets.push(changeset); + } + } + if !suitable_group_ids.is_empty() { + for group_id in suitable_group_ids.iter() { + if let Some(group) = self.context.get_mut_group(group_id) { + group.add_row(row_detail.clone()); + } + } + } else if let Some(no_status_group) = self.context.get_mut_no_status_group() { + no_status_group.add_row(row_detail.clone()); + let changeset = GroupRowsNotificationPB::insert( + no_status_group.id.clone(), + vec![InsertedRowPB { + row_meta: row_detail.into(), + index: Some(index as i32), + is_new: true, + }], + ); + changesets.push(changeset); + } + } + + changesets + } + fn did_update_group_row( &mut self, old_row_detail: &Option<RowDetail>, @@ -317,27 +338,21 @@ where Ok(result) } - fn did_delete_delete_row( - &mut self, - row: &Row, - field: &Field, - ) -> FlowyResult<DidMoveGroupRowResult> { - // if the cell_rev is none, then the row must in the default group. + fn did_delete_row(&mut self, row: &Row) -> FlowyResult<DidMoveGroupRowResult> { let mut result = DidMoveGroupRowResult { deleted_group: None, row_changesets: vec![], }; + // early return if the row is not in the default group if let Some(cell) = row.cells.get(&self.grouping_field_id) { - let cell_bytes = get_cell_protobuf(cell, field, None); - let cell_data = cell_bytes.parser::<P>()?; - if !cell_data.is_empty() { - tracing::error!("did_delete_delete_row {:?}", cell); - result.row_changesets = self.delete_row(row, &cell_data); + let cell_data = <T as TypeOption>::CellData::from(cell); + if !cell_data.is_cell_empty() { + (result.deleted_group, result.row_changesets) = self.delete_row(row, &cell_data); return Ok(result); } } - match self.context.get_no_status_group() { + match self.context.get_mut_no_status_group() { None => { tracing::error!("Unexpected None value. It should have the no status group"); }, @@ -345,6 +360,7 @@ where if !no_status_group.contains_row(&row.id) { tracing::error!("The row: {:?} should be in the no status group", row.id); } + no_status_group.remove_row(&row.id); result.row_changesets = vec![GroupRowsNotificationPB::delete( no_status_group.id.clone(), vec![row.id.clone().into_inner()], @@ -380,20 +396,33 @@ where Ok(None) } - fn apply_group_setting_changeset(&mut self, changeset: GroupChangesets) -> FlowyResult<()> { - for group_changeset in changeset.update_groups { - if let Err(e) = self.context.update_group(group_changeset) { - tracing::error!("Failed to update group: {:?}", e); + async fn apply_group_changeset( + &mut self, + changeset: &GroupChangesets, + ) -> FlowyResult<(Vec<GroupPB>, TypeOptionData)> { + for group_changeset in changeset.changesets.iter() { + self.context.update_group(group_changeset)?; + } + let mut type_option_data = TypeOptionData::new(); + for group_changeset in changeset.changesets.iter() { + if let Some(new_type_option_data) = self + .operation_interceptor + .type_option_from_group_changeset(group_changeset, &self.type_option, &self.context.view_id) + .await + { + type_option_data.extend(new_type_option_data); } } - Ok(()) - } - - fn apply_group_configuration_setting_changeset( - &mut self, - changeset: GroupSettingChangeset, - ) -> FlowyResult<Option<GroupSetting>> { - self.context.update_configuration(changeset) + let updated_groups = changeset + .changesets + .iter() + .filter_map(|changeset| { + self + .get_group(&changeset.group_id) + .map(|(_, group)| GroupPB::from(group)) + }) + .collect::<Vec<_>>(); + Ok((updated_groups, type_option_data)) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/checkbox_controller.rs b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/checkbox_controller.rs index 221bdbbea0f4..accd8d3f31fb 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/checkbox_controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/checkbox_controller.rs @@ -1,20 +1,20 @@ -use std::sync::Arc; - +use async_trait::async_trait; use collab_database::fields::Field; use collab_database::rows::{new_cell_builder, Cell, Cells, Row, RowDetail}; use serde::{Deserialize, Serialize}; -use crate::entities::{FieldType, GroupRowsNotificationPB, InsertedRowPB, RowMetaPB}; +use crate::entities::{FieldType, GroupPB, GroupRowsNotificationPB, InsertedRowPB, RowMetaPB}; use crate::services::cell::insert_checkbox_cell; use crate::services::field::{ - CheckboxCellData, CheckboxCellDataParser, CheckboxTypeOption, CHECK, UNCHECK, + CheckboxCellDataParser, CheckboxTypeOption, TypeOption, CHECK, UNCHECK, }; use crate::services::group::action::GroupCustomize; use crate::services::group::configuration::GroupContext; -use crate::services::group::controller::{ - BaseGroupController, GroupController, GroupsBuilder, MoveGroupRowContext, +use crate::services::group::controller::{BaseGroupController, GroupController}; +use crate::services::group::{ + move_group_row, GeneratedGroupConfig, GeneratedGroups, Group, GroupOperationInterceptor, + GroupsBuilder, MoveGroupRowContext, }; -use crate::services::group::{move_group_row, GeneratedGroupConfig, GeneratedGroups, Group}; #[derive(Default, Serialize, Deserialize)] pub struct CheckboxGroupConfiguration { @@ -24,14 +24,15 @@ pub struct CheckboxGroupConfiguration { pub type CheckboxGroupController = BaseGroupController< CheckboxGroupConfiguration, CheckboxTypeOption, - CheckboxGroupGenerator, + CheckboxGroupBuilder, CheckboxCellDataParser, + CheckboxGroupOperationInterceptorImpl, >; pub type CheckboxGroupContext = GroupContext<CheckboxGroupConfiguration>; impl GroupCustomize for CheckboxGroupController { - type CellData = CheckboxCellData; + type GroupTypeOption = CheckboxTypeOption; fn placeholder_cell(&self) -> Option<Cell> { Some( new_cell_builder(FieldType::Checkbox) @@ -40,7 +41,11 @@ impl GroupCustomize for CheckboxGroupController { ) } - fn can_group(&self, content: &str, cell_data: &Self::CellData) -> bool { + fn can_group( + &self, + content: &str, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellData, + ) -> bool { if cell_data.is_check() { content == CHECK } else { @@ -51,7 +56,7 @@ impl GroupCustomize for CheckboxGroupController { fn add_or_remove_row_when_cell_changed( &mut self, row_detail: &RowDetail, - cell_data: &Self::CellData, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, ) -> Vec<GroupRowsNotificationPB> { let mut changesets = vec![]; self.context.iter_mut_status_groups(|group| { @@ -100,7 +105,11 @@ impl GroupCustomize for CheckboxGroupController { changesets } - fn delete_row(&mut self, row: &Row, _cell_data: &Self::CellData) -> Vec<GroupRowsNotificationPB> { + fn delete_row( + &mut self, + row: &Row, + _cell_data: &<Self::GroupTypeOption as TypeOption>::CellData, + ) -> (Option<GroupPB>, Vec<GroupRowsNotificationPB>) { let mut changesets = vec![]; self.context.iter_mut_groups(|group| { let mut changeset = GroupRowsNotificationPB::new(group.id.clone()); @@ -113,12 +122,12 @@ impl GroupCustomize for CheckboxGroupController { changesets.push(changeset); } }); - changesets + (None, changesets) } fn move_row( &mut self, - _cell_data: &Self::CellData, + _cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, mut context: MoveGroupRowContext, ) -> Vec<GroupRowsNotificationPB> { let mut group_changeset = vec![]; @@ -132,7 +141,7 @@ impl GroupCustomize for CheckboxGroupController { } impl GroupController for CheckboxGroupController { - fn did_update_field_type_option(&mut self, _field: &Arc<Field>) { + fn did_update_field_type_option(&mut self, _field: &Field) { // Do nothing } @@ -146,23 +155,18 @@ impl GroupController for CheckboxGroupController { }, } } - - fn did_create_row(&mut self, row_detail: &RowDetail, group_id: &str) { - if let Some(group) = self.context.get_mut_group(group_id) { - group.add_row(row_detail.clone()) - } - } } -pub struct CheckboxGroupGenerator(); -impl GroupsBuilder for CheckboxGroupGenerator { +pub struct CheckboxGroupBuilder(); +#[async_trait] +impl GroupsBuilder for CheckboxGroupBuilder { type Context = CheckboxGroupContext; - type TypeOptionType = CheckboxTypeOption; + type GroupTypeOption = CheckboxTypeOption; - fn build( + async fn build( _field: &Field, _context: &Self::Context, - _type_option: &Option<Self::TypeOptionType>, + _type_option: &Self::GroupTypeOption, ) -> GeneratedGroups { let check_group = GeneratedGroupConfig { group: Group::new(CHECK.to_string(), "".to_string()), @@ -180,3 +184,10 @@ impl GroupsBuilder for CheckboxGroupGenerator { } } } + +pub struct CheckboxGroupOperationInterceptorImpl {} + +#[async_trait] +impl GroupOperationInterceptor for CheckboxGroupOperationInterceptorImpl { + type GroupTypeOption = CheckboxTypeOption; +} diff --git a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/date_controller.rs b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/date_controller.rs index 3c64e4ff26dd..1a3f2ee37115 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/date_controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/date_controller.rs @@ -1,7 +1,7 @@ use std::format; use std::str::FromStr; -use std::sync::Arc; +use async_trait::async_trait; use chrono::{ DateTime, Datelike, Days, Duration, Local, NaiveDate, NaiveDateTime, Offset, TimeZone, }; @@ -15,18 +15,16 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; use flowy_error::FlowyResult; use crate::entities::{ - DateCellDataPB, FieldType, GroupPB, GroupRowsNotificationPB, InsertedGroupPB, InsertedRowPB, - RowMetaPB, + FieldType, GroupPB, GroupRowsNotificationPB, InsertedGroupPB, InsertedRowPB, RowMetaPB, }; use crate::services::cell::insert_date_cell; -use crate::services::field::{DateCellData, DateCellDataParser, DateTypeOption}; +use crate::services::field::{DateCellData, DateCellDataParser, DateTypeOption, TypeOption}; use crate::services::group::action::GroupCustomize; use crate::services::group::configuration::GroupContext; -use crate::services::group::controller::{ - BaseGroupController, GroupController, GroupsBuilder, MoveGroupRowContext, -}; +use crate::services::group::controller::{BaseGroupController, GroupController}; use crate::services::group::{ make_no_status_group, move_group_row, GeneratedGroupConfig, GeneratedGroups, Group, + GroupOperationInterceptor, GroupsBuilder, MoveGroupRowContext, }; pub trait GroupConfigurationContentSerde: Sized + Send + Sync { @@ -63,14 +61,15 @@ pub enum DateCondition { pub type DateGroupController = BaseGroupController< DateGroupConfiguration, DateTypeOption, - DateGroupGenerator, + DateGroupBuilder, DateCellDataParser, + DateGroupOperationInterceptorImpl, >; pub type DateGroupContext = GroupContext<DateGroupConfiguration>; impl GroupCustomize for DateGroupController { - type CellData = DateCellDataPB; + type GroupTypeOption = DateTypeOption; fn placeholder_cell(&self) -> Option<Cell> { Some( @@ -80,47 +79,48 @@ impl GroupCustomize for DateGroupController { ) } - fn can_group(&self, content: &str, cell_data: &Self::CellData) -> bool { + fn can_group( + &self, + content: &str, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellData, + ) -> bool { content == group_id( - &cell_data.into(), - self.type_option.as_ref(), + cell_data, + &self.type_option, &self.context.get_setting_content(), ) } fn create_or_delete_group_when_cell_changed( &mut self, - row_detail: &RowDetail, - old_cell_data: Option<&Self::CellData>, - cell_data: &Self::CellData, + _row_detail: &RowDetail, + _old_cell_data: Option<&<Self::GroupTypeOption as TypeOption>::CellProtobufType>, + _cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, ) -> FlowyResult<(Option<InsertedGroupPB>, Option<GroupPB>)> { let setting_content = self.context.get_setting_content(); let mut inserted_group = None; if self .context .get_group(&group_id( - &cell_data.into(), - self.type_option.as_ref(), + &_cell_data.into(), + &self.type_option, &setting_content, )) .is_none() { - let group = make_group_from_date_cell( - &cell_data.into(), - self.type_option.as_ref(), - &setting_content, - ); + let group = + make_group_from_date_cell(&_cell_data.into(), &self.type_option, &setting_content); let mut new_group = self.context.add_new_group(group)?; - new_group.group.rows.push(RowMetaPB::from(row_detail)); + new_group.group.rows.push(RowMetaPB::from(_row_detail)); inserted_group = Some(new_group); } // Delete the old group if there are no rows in that group - let deleted_group = match old_cell_data.and_then(|old_cell_data| { + let deleted_group = match _old_cell_data.and_then(|old_cell_data| { self.context.get_group(&group_id( &old_cell_data.into(), - self.type_option.as_ref(), + &self.type_option, &setting_content, )) }) { @@ -148,19 +148,13 @@ impl GroupCustomize for DateGroupController { fn add_or_remove_row_when_cell_changed( &mut self, row_detail: &RowDetail, - cell_data: &Self::CellData, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, ) -> Vec<GroupRowsNotificationPB> { let mut changesets = vec![]; let setting_content = self.context.get_setting_content(); self.context.iter_mut_status_groups(|group| { let mut changeset = GroupRowsNotificationPB::new(group.id.clone()); - if group.id - == group_id( - &cell_data.into(), - self.type_option.as_ref(), - &setting_content, - ) - { + if group.id == group_id(&cell_data.into(), &self.type_option, &setting_content) { if !group.contains_row(&row_detail.row.id) { changeset .inserted_rows @@ -181,7 +175,11 @@ impl GroupCustomize for DateGroupController { changesets } - fn delete_row(&mut self, row: &Row, _cell_data: &Self::CellData) -> Vec<GroupRowsNotificationPB> { + fn delete_row( + &mut self, + row: &Row, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellData, + ) -> (Option<GroupPB>, Vec<GroupRowsNotificationPB>) { let mut changesets = vec![]; self.context.iter_mut_groups(|group| { let mut changeset = GroupRowsNotificationPB::new(group.id.clone()); @@ -194,12 +192,28 @@ impl GroupCustomize for DateGroupController { changesets.push(changeset); } }); - changesets + + let setting_content = self.context.get_setting_content(); + let deleted_group = + match self + .context + .get_group(&group_id(cell_data, &self.type_option, &setting_content)) + { + Some((_, group)) if group.rows.len() == 1 => Some(group.clone()), + _ => None, + }; + + let deleted_group = deleted_group.map(|group| { + let _ = self.context.delete_group(&group.id); + group.into() + }); + + (deleted_group, changesets) } fn move_row( &mut self, - _cell_data: &Self::CellData, + _cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, mut context: MoveGroupRowContext, ) -> Vec<GroupRowsNotificationPB> { let mut group_changeset = vec![]; @@ -214,13 +228,13 @@ impl GroupCustomize for DateGroupController { fn delete_group_when_move_row( &mut self, _row: &Row, - cell_data: &Self::CellData, + _cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, ) -> Option<GroupPB> { let mut deleted_group = None; let setting_content = self.context.get_setting_content(); if let Some((_, group)) = self.context.get_group(&group_id( - &cell_data.into(), - self.type_option.as_ref(), + &_cell_data.into(), + &self.type_option, &setting_content, )) { if group.rows.len() == 1 { @@ -237,7 +251,7 @@ impl GroupCustomize for DateGroupController { } impl GroupController for DateGroupController { - fn did_update_field_type_option(&mut self, _field: &Arc<Field>) {} + fn did_update_field_type_option(&mut self, _field: &Field) {} fn will_create_row(&mut self, cells: &mut Cells, field: &Field, group_id: &str) { match self.context.get_group(group_id) { @@ -249,26 +263,21 @@ impl GroupController for DateGroupController { }, } } - - fn did_create_row(&mut self, row_detail: &RowDetail, group_id: &str) { - if let Some(group) = self.context.get_mut_group(group_id) { - group.add_row(row_detail.clone()) - } - } } -pub struct DateGroupGenerator(); -impl GroupsBuilder for DateGroupGenerator { +pub struct DateGroupBuilder(); +#[async_trait] +impl GroupsBuilder for DateGroupBuilder { type Context = DateGroupContext; - type TypeOptionType = DateTypeOption; + type GroupTypeOption = DateTypeOption; - fn build( + async fn build( field: &Field, context: &Self::Context, - type_option: &Option<Self::TypeOptionType>, + type_option: &Self::GroupTypeOption, ) -> GeneratedGroups { // Read all the cells for the grouping field - let cells = futures::executor::block_on(context.get_all_cells()); + let cells = context.get_all_cells().await; // Generate the groups let mut group_configs: Vec<GeneratedGroupConfig> = cells @@ -276,8 +285,7 @@ impl GroupsBuilder for DateGroupGenerator { .flat_map(|value| value.into_date_field_cell_data()) .filter(|cell| cell.timestamp.is_some()) .map(|cell| { - let group = - make_group_from_date_cell(&cell, type_option.as_ref(), &context.get_setting_content()); + let group = make_group_from_date_cell(&cell, type_option, &context.get_setting_content()); GeneratedGroupConfig { filter_content: group.id.clone(), group, @@ -296,7 +304,7 @@ impl GroupsBuilder for DateGroupGenerator { fn make_group_from_date_cell( cell_data: &DateCellData, - type_option: Option<&DateTypeOption>, + type_option: &DateTypeOption, setting_content: &str, ) -> Group { let group_id = group_id(cell_data, type_option, setting_content); @@ -310,11 +318,9 @@ const GROUP_ID_DATE_FORMAT: &str = "%Y/%m/%d"; fn group_id( cell_data: &DateCellData, - type_option: Option<&DateTypeOption>, + type_option: &DateTypeOption, setting_content: &str, ) -> String { - let binding = DateTypeOption::default(); - let type_option = type_option.unwrap_or(&binding); let config = DateGroupConfiguration::from_json(setting_content).unwrap_or_default(); let date_time = date_time_from_timestamp(cell_data.timestamp, &type_option.timezone_id); @@ -373,11 +379,9 @@ fn group_id( fn group_name_from_id( group_id: &str, - type_option: Option<&DateTypeOption>, + type_option: &DateTypeOption, setting_content: &str, ) -> String { - let binding = DateTypeOption::default(); - let type_option = type_option.unwrap_or(&binding); let config = DateGroupConfiguration::from_json(setting_content).unwrap_or_default(); let date = NaiveDate::parse_from_str(group_id, GROUP_ID_DATE_FORMAT).unwrap(); @@ -449,6 +453,13 @@ fn date_time_from_timestamp(timestamp: Option<i64>, timezone_id: &str) -> DateTi } } +pub struct DateGroupOperationInterceptorImpl {} + +#[async_trait] +impl GroupOperationInterceptor for DateGroupOperationInterceptorImpl { + type GroupTypeOption = DateTypeOption; +} + #[cfg(test)] mod tests { use std::vec; @@ -582,16 +593,11 @@ mod tests { ]; for (i, test) in tests.iter().enumerate() { - let group_id = group_id( - &test.cell_data, - Some(test.type_option), - &test.setting_content, - ); + let group_id = group_id(&test.cell_data, test.type_option, &test.setting_content); assert_eq!(test.exp_group_id, group_id, "test {}", i); if !test.exp_group_name.is_empty() { - let group_name = - group_name_from_id(&group_id, Some(test.type_option), &test.setting_content); + let group_name = group_name_from_id(&group_id, test.type_option, &test.setting_content); assert_eq!(test.exp_group_name, group_name, "test {}", i); } } diff --git a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/default_controller.rs b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/default_controller.rs index fe9c85f4e5a2..b3ba30127e42 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/default_controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/default_controller.rs @@ -1,18 +1,18 @@ use std::sync::Arc; -use collab_database::fields::Field; +use async_trait::async_trait; +use collab_database::fields::{Field, TypeOptionData}; use collab_database::rows::{Cells, Row, RowDetail}; use flowy_error::FlowyResult; -use crate::entities::GroupChangesPB; +use crate::entities::{ + GroupChangesPB, GroupPB, GroupRowsNotificationPB, InsertedGroupPB, InsertedRowPB, +}; use crate::services::group::action::{ DidMoveGroupRowResult, DidUpdateGroupRowResult, GroupControllerOperation, }; -use crate::services::group::{ - GroupChangesets, GroupController, GroupData, GroupSetting, GroupSettingChangeset, - MoveGroupRowContext, -}; +use crate::services::group::{GroupChangesets, GroupController, GroupData, MoveGroupRowContext}; /// A [DefaultGroupController] is used to handle the group actions for the [FieldType] that doesn't /// implement its own group controller. The default group controller only contains one group, which @@ -32,6 +32,7 @@ impl DefaultGroupController { field.id.clone(), "".to_owned(), "".to_owned(), + true, ); Self { field_id: field.id.clone(), @@ -40,12 +41,13 @@ impl DefaultGroupController { } } +#[async_trait] impl GroupControllerOperation for DefaultGroupController { fn field_id(&self) -> &str { &self.field_id } - fn groups(&self) -> Vec<&GroupData> { + fn get_all_groups(&self) -> Vec<&GroupData> { vec![&self.group] } @@ -60,10 +62,34 @@ impl GroupControllerOperation for DefaultGroupController { Ok(()) } + fn create_group( + &mut self, + _name: String, + ) -> FlowyResult<(Option<TypeOptionData>, Option<InsertedGroupPB>)> { + Ok((None, None)) + } + fn move_group(&mut self, _from_group_id: &str, _to_group_id: &str) -> FlowyResult<()> { Ok(()) } + fn did_create_row( + &mut self, + row_detail: &RowDetail, + index: usize, + ) -> Vec<GroupRowsNotificationPB> { + self.group.add_row(row_detail.clone()); + + vec![GroupRowsNotificationPB::insert( + self.group.id.clone(), + vec![InsertedRowPB { + row_meta: row_detail.into(), + index: Some(index as i32), + is_new: true, + }], + )] + } + fn did_update_group_row( &mut self, _old_row_detail: &Option<RowDetail>, @@ -77,14 +103,15 @@ impl GroupControllerOperation for DefaultGroupController { }) } - fn did_delete_delete_row( - &mut self, - _row: &Row, - _field: &Field, - ) -> FlowyResult<DidMoveGroupRowResult> { + fn did_delete_row(&mut self, row: &Row) -> FlowyResult<DidMoveGroupRowResult> { + let mut changeset = GroupRowsNotificationPB::new(self.group.id.clone()); + if self.group.contains_row(&row.id) { + self.group.remove_row(&row.id); + changeset.deleted_rows.push(row.id.clone().into_inner()); + } Ok(DidMoveGroupRowResult { deleted_group: None, - row_changesets: vec![], + row_changesets: vec![changeset], }) } @@ -102,24 +129,18 @@ impl GroupControllerOperation for DefaultGroupController { Ok(None) } - fn apply_group_setting_changeset(&mut self, _changeset: GroupChangesets) -> FlowyResult<()> { - Ok(()) - } - - fn apply_group_configuration_setting_changeset( + async fn apply_group_changeset( &mut self, - _changeset: GroupSettingChangeset, - ) -> FlowyResult<Option<GroupSetting>> { - Ok(None) + _changeset: &GroupChangesets, + ) -> FlowyResult<(Vec<GroupPB>, TypeOptionData)> { + Ok((Vec::new(), TypeOptionData::default())) } } impl GroupController for DefaultGroupController { - fn did_update_field_type_option(&mut self, _field: &Arc<Field>) { + fn did_update_field_type_option(&mut self, _field: &Field) { // Do nothing } fn will_create_row(&mut self, _cells: &mut Cells, _field: &Field, _group_id: &str) {} - - fn did_create_row(&mut self, _row_detail: &RowDetail, _group_id: &str) {} } diff --git a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/multi_select_controller.rs b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/multi_select_controller.rs index 1fd9d3f2f6ae..f7794a96240f 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/multi_select_controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/multi_select_controller.rs @@ -1,21 +1,21 @@ -use std::sync::Arc; - +use async_trait::async_trait; use collab_database::fields::{Field, TypeOptionData}; use collab_database::rows::{new_cell_builder, Cell, Cells, Row, RowDetail}; +use flowy_error::FlowyResult; use serde::{Deserialize, Serialize}; -use crate::entities::{FieldType, GroupRowsNotificationPB, SelectOptionCellDataPB}; +use crate::entities::{FieldType, GroupPB, GroupRowsNotificationPB, InsertedGroupPB}; use crate::services::cell::insert_select_option_cell; use crate::services::field::{ MultiSelectTypeOption, SelectOption, SelectOptionCellDataParser, SelectTypeOptionSharedAction, + TypeOption, }; use crate::services::group::action::GroupCustomize; -use crate::services::group::controller::{ - BaseGroupController, GroupController, GroupsBuilder, MoveGroupRowContext, -}; +use crate::services::group::controller::{BaseGroupController, GroupController}; use crate::services::group::{ add_or_remove_select_option_row, generate_select_option_groups, make_no_status_group, - move_group_row, remove_select_option_row, GeneratedGroups, GroupContext, + move_group_row, remove_select_option_row, GeneratedGroups, Group, GroupChangeset, GroupContext, + GroupOperationInterceptor, GroupsBuilder, MoveGroupRowContext, }; #[derive(Default, Serialize, Deserialize)] @@ -28,18 +28,20 @@ pub type MultiSelectOptionGroupContext = GroupContext<MultiSelectGroupConfigurat pub type MultiSelectGroupController = BaseGroupController< MultiSelectGroupConfiguration, MultiSelectTypeOption, - MultiSelectGroupGenerator, + MultiSelectGroupBuilder, SelectOptionCellDataParser, + MultiSelectGroupOperationInterceptorImpl, >; impl GroupCustomize for MultiSelectGroupController { - type CellData = SelectOptionCellDataPB; - - fn can_group(&self, content: &str, cell_data: &Self::CellData) -> bool { - cell_data - .select_options - .iter() - .any(|option| option.id == content) + type GroupTypeOption = MultiSelectTypeOption; + + fn can_group( + &self, + content: &str, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellData, + ) -> bool { + cell_data.iter().any(|option_id| option_id == content) } fn placeholder_cell(&self) -> Option<Cell> { @@ -53,7 +55,7 @@ impl GroupCustomize for MultiSelectGroupController { fn add_or_remove_row_when_cell_changed( &mut self, row_detail: &RowDetail, - cell_data: &Self::CellData, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, ) -> Vec<GroupRowsNotificationPB> { let mut changesets = vec![]; self.context.iter_mut_status_groups(|group| { @@ -64,19 +66,23 @@ impl GroupCustomize for MultiSelectGroupController { changesets } - fn delete_row(&mut self, row: &Row, cell_data: &Self::CellData) -> Vec<GroupRowsNotificationPB> { + fn delete_row( + &mut self, + row: &Row, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellData, + ) -> (Option<GroupPB>, Vec<GroupRowsNotificationPB>) { let mut changesets = vec![]; self.context.iter_mut_status_groups(|group| { if let Some(changeset) = remove_select_option_row(group, cell_data, row) { changesets.push(changeset); } }); - changesets + (None, changesets) } fn move_row( &mut self, - _cell_data: &Self::CellData, + _cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, mut context: MoveGroupRowContext, ) -> Vec<GroupRowsNotificationPB> { let mut group_changeset = vec![]; @@ -87,10 +93,24 @@ impl GroupCustomize for MultiSelectGroupController { }); group_changeset } + + fn generate_new_group( + &mut self, + name: String, + ) -> FlowyResult<(Option<TypeOptionData>, Option<InsertedGroupPB>)> { + let mut new_type_option = self.type_option.clone(); + let new_select_option = self.type_option.create_option(&name); + new_type_option.insert_option(new_select_option.clone()); + + let new_group = Group::new(new_select_option.id, new_select_option.name); + let inserted_group_pb = self.context.add_new_group(new_group)?; + + Ok((Some(new_type_option.into()), Some(inserted_group_pb))) + } } impl GroupController for MultiSelectGroupController { - fn did_update_field_type_option(&mut self, _field: &Arc<Field>) {} + fn did_update_field_type_option(&mut self, _field: &Field) {} fn will_create_row(&mut self, cells: &mut Cells, field: &Field, group_id: &str) { match self.context.get_group(group_id) { @@ -101,55 +121,56 @@ impl GroupController for MultiSelectGroupController { }, } } - - fn did_create_row(&mut self, row_detail: &RowDetail, group_id: &str) { - if let Some(group) = self.context.get_mut_group(group_id) { - group.add_row(row_detail.clone()) - } - } - - fn update_group_name(&mut self, group_id: &str, group_name: &str) -> Option<TypeOptionData> { - match &self.type_option { - Some(type_option) => { - let select_option = type_option - .options - .iter() - .find(|option| option.id == group_id) - .unwrap(); - - let new_select_option = SelectOption { - name: group_name.to_owned(), - ..select_option.to_owned() - }; - - let mut new_type_option = type_option.clone(); - new_type_option.insert_option(new_select_option); - - Some(new_type_option.to_type_option_data()) - }, - None => None, - } - } } -pub struct MultiSelectGroupGenerator; -impl GroupsBuilder for MultiSelectGroupGenerator { +pub struct MultiSelectGroupBuilder; +#[async_trait] +impl GroupsBuilder for MultiSelectGroupBuilder { type Context = MultiSelectOptionGroupContext; - type TypeOptionType = MultiSelectTypeOption; + type GroupTypeOption = MultiSelectTypeOption; - fn build( + async fn build( field: &Field, _context: &Self::Context, - type_option: &Option<Self::TypeOptionType>, + type_option: &Self::GroupTypeOption, ) -> GeneratedGroups { - let group_configs = match type_option { - None => vec![], - Some(type_option) => generate_select_option_groups(&field.id, &type_option.options), - }; - + let group_configs = generate_select_option_groups(&field.id, &type_option.options); GeneratedGroups { no_status_group: Some(make_no_status_group(field)), group_configs, } } } + +pub struct MultiSelectGroupOperationInterceptorImpl; + +#[async_trait] +impl GroupOperationInterceptor for MultiSelectGroupOperationInterceptorImpl { + type GroupTypeOption = MultiSelectTypeOption; + + #[tracing::instrument(level = "trace", skip_all)] + async fn type_option_from_group_changeset( + &self, + changeset: &GroupChangeset, + type_option: &Self::GroupTypeOption, + _view_id: &str, + ) -> Option<TypeOptionData> { + if let Some(name) = &changeset.name { + let mut new_type_option = type_option.clone(); + let select_option = type_option + .options + .iter() + .find(|option| option.id == changeset.group_id) + .unwrap(); + + let new_select_option = SelectOption { + name: name.to_owned(), + ..select_option.to_owned() + }; + new_type_option.insert_option(new_select_option); + return Some(new_type_option.into()); + } + + None + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/single_select_controller.rs b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/single_select_controller.rs index c00ab4c3a164..a92c79c624bd 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/single_select_controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/single_select_controller.rs @@ -1,21 +1,23 @@ -use std::sync::Arc; - +use async_trait::async_trait; use collab_database::fields::{Field, TypeOptionData}; use collab_database::rows::{new_cell_builder, Cell, Cells, Row, RowDetail}; +use flowy_error::FlowyResult; use serde::{Deserialize, Serialize}; -use crate::entities::{FieldType, GroupRowsNotificationPB, SelectOptionCellDataPB}; +use crate::entities::{FieldType, GroupPB, GroupRowsNotificationPB, InsertedGroupPB}; use crate::services::cell::insert_select_option_cell; use crate::services::field::{ SelectOption, SelectOptionCellDataParser, SelectTypeOptionSharedAction, SingleSelectTypeOption, + TypeOption, }; use crate::services::group::action::GroupCustomize; -use crate::services::group::controller::{ - BaseGroupController, GroupController, GroupsBuilder, MoveGroupRowContext, -}; +use crate::services::group::controller::{BaseGroupController, GroupController}; use crate::services::group::controller_impls::select_option_controller::util::*; use crate::services::group::entities::GroupData; -use crate::services::group::{make_no_status_group, GeneratedGroups, GroupContext}; +use crate::services::group::{ + make_no_status_group, GeneratedGroups, Group, GroupChangeset, GroupContext, + GroupOperationInterceptor, GroupsBuilder, MoveGroupRowContext, +}; #[derive(Default, Serialize, Deserialize)] pub struct SingleSelectGroupConfiguration { @@ -28,17 +30,19 @@ pub type SingleSelectOptionGroupContext = GroupContext<SingleSelectGroupConfigur pub type SingleSelectGroupController = BaseGroupController< SingleSelectGroupConfiguration, SingleSelectTypeOption, - SingleSelectGroupGenerator, + SingleSelectGroupBuilder, SelectOptionCellDataParser, + SingleSelectGroupOperationInterceptorImpl, >; impl GroupCustomize for SingleSelectGroupController { - type CellData = SelectOptionCellDataPB; - fn can_group(&self, content: &str, cell_data: &Self::CellData) -> bool { - cell_data - .select_options - .iter() - .any(|option| option.id == content) + type GroupTypeOption = SingleSelectTypeOption; + fn can_group( + &self, + content: &str, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellData, + ) -> bool { + cell_data.iter().any(|option_id| option_id == content) } fn placeholder_cell(&self) -> Option<Cell> { @@ -52,7 +56,7 @@ impl GroupCustomize for SingleSelectGroupController { fn add_or_remove_row_when_cell_changed( &mut self, row_detail: &RowDetail, - cell_data: &Self::CellData, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, ) -> Vec<GroupRowsNotificationPB> { let mut changesets = vec![]; self.context.iter_mut_status_groups(|group| { @@ -63,19 +67,23 @@ impl GroupCustomize for SingleSelectGroupController { changesets } - fn delete_row(&mut self, row: &Row, cell_data: &Self::CellData) -> Vec<GroupRowsNotificationPB> { + fn delete_row( + &mut self, + row: &Row, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellData, + ) -> (Option<GroupPB>, Vec<GroupRowsNotificationPB>) { let mut changesets = vec![]; self.context.iter_mut_status_groups(|group| { if let Some(changeset) = remove_select_option_row(group, cell_data, row) { changesets.push(changeset); } }); - changesets + (None, changesets) } fn move_row( &mut self, - _cell_data: &Self::CellData, + _cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, mut context: MoveGroupRowContext, ) -> Vec<GroupRowsNotificationPB> { let mut group_changeset = vec![]; @@ -86,10 +94,27 @@ impl GroupCustomize for SingleSelectGroupController { }); group_changeset } + + fn generate_new_group( + &mut self, + name: String, + ) -> FlowyResult<(Option<TypeOptionData>, Option<InsertedGroupPB>)> { + let mut new_type_option = self.type_option.clone(); + let new_select_option = self.type_option.create_option(&name); + new_type_option.insert_option_at_index( + new_select_option.clone(), + Some(new_type_option.options.len()), + ); + + let new_group = Group::new(new_select_option.id, new_select_option.name); + let inserted_group_pb = self.context.add_new_group(new_group)?; + + Ok((Some(new_type_option.into()), Some(inserted_group_pb))) + } } impl GroupController for SingleSelectGroupController { - fn did_update_field_type_option(&mut self, _field: &Arc<Field>) {} + fn did_update_field_type_option(&mut self, _field: &Field) {} fn will_create_row(&mut self, cells: &mut Cells, field: &Field, group_id: &str) { let group: Option<&mut GroupData> = self.context.get_mut_group(group_id); @@ -101,50 +126,19 @@ impl GroupController for SingleSelectGroupController { }, } } - - fn did_create_row(&mut self, row_detail: &RowDetail, group_id: &str) { - if let Some(group) = self.context.get_mut_group(group_id) { - group.add_row(row_detail.clone()) - } - } - - fn update_group_name(&mut self, group_id: &str, group_name: &str) -> Option<TypeOptionData> { - match &self.type_option { - Some(type_option) => { - let select_option = type_option - .options - .iter() - .find(|option| option.id == group_id) - .unwrap(); - - let new_select_option = SelectOption { - name: group_name.to_owned(), - ..select_option.to_owned() - }; - - let mut new_type_option = type_option.clone(); - new_type_option.insert_option(new_select_option); - - Some(new_type_option.to_type_option_data()) - }, - None => None, - } - } } -pub struct SingleSelectGroupGenerator(); -impl GroupsBuilder for SingleSelectGroupGenerator { +pub struct SingleSelectGroupBuilder(); +#[async_trait] +impl GroupsBuilder for SingleSelectGroupBuilder { type Context = SingleSelectOptionGroupContext; - type TypeOptionType = SingleSelectTypeOption; - fn build( + type GroupTypeOption = SingleSelectTypeOption; + async fn build( field: &Field, _context: &Self::Context, - type_option: &Option<Self::TypeOptionType>, + type_option: &Self::GroupTypeOption, ) -> GeneratedGroups { - let group_configs = match type_option { - None => vec![], - Some(type_option) => generate_select_option_groups(&field.id, &type_option.options), - }; + let group_configs = generate_select_option_groups(&field.id, &type_option.options); GeneratedGroups { no_status_group: Some(make_no_status_group(field)), @@ -152,3 +146,36 @@ impl GroupsBuilder for SingleSelectGroupGenerator { } } } + +pub struct SingleSelectGroupOperationInterceptorImpl; + +#[async_trait] +impl GroupOperationInterceptor for SingleSelectGroupOperationInterceptorImpl { + type GroupTypeOption = SingleSelectTypeOption; + + #[tracing::instrument(level = "trace", skip_all)] + async fn type_option_from_group_changeset( + &self, + changeset: &GroupChangeset, + type_option: &Self::GroupTypeOption, + _view_id: &str, + ) -> Option<TypeOptionData> { + if let Some(name) = &changeset.name { + let mut new_type_option = type_option.clone(); + let select_option = type_option + .options + .iter() + .find(|option| option.id == changeset.group_id) + .unwrap(); + + let new_select_option = SelectOption { + name: name.to_owned(), + ..select_option.to_owned() + }; + new_type_option.insert_option(new_select_option); + return Some(new_type_option.into()); + } + + None + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/util.rs b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/util.rs index 2251a4ae0639..4ed745cad516 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/util.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/util.rs @@ -8,9 +8,8 @@ use crate::entities::{ use crate::services::cell::{ insert_checkbox_cell, insert_date_cell, insert_select_option_cell, insert_url_cell, }; -use crate::services::field::{SelectOption, CHECK}; -use crate::services::group::controller::MoveGroupRowContext; -use crate::services::group::{GeneratedGroupConfig, Group, GroupData}; +use crate::services::field::{SelectOption, SelectOptionIds, CHECK}; +use crate::services::group::{GeneratedGroupConfig, Group, GroupData, MoveGroupRowContext}; pub fn add_or_remove_select_option_row( group: &mut GroupData, @@ -52,12 +51,12 @@ pub fn add_or_remove_select_option_row( pub fn remove_select_option_row( group: &mut GroupData, - cell_data: &SelectOptionCellDataPB, + cell_data: &SelectOptionIds, row: &Row, ) -> Option<GroupRowsNotificationPB> { let mut changeset = GroupRowsNotificationPB::new(group.id.clone()); - cell_data.select_options.iter().for_each(|option| { - if option.id == group.id && group.contains_row(&row.id) { + cell_data.iter().for_each(|option_id| { + if option_id == &group.id && group.contains_row(&row.id) { group.remove_row(&row.id); changeset.deleted_rows.push(row.id.clone().into_inner()); } @@ -186,6 +185,7 @@ pub fn make_inserted_cell(group_id: &str, field: &Field) -> Option<Cell> { }, } } + pub fn generate_select_option_groups( _field_id: &str, options: &[SelectOption], diff --git a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/url_controller.rs b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/url_controller.rs index f08ab426d53c..0b5b3539c762 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/url_controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/url_controller.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use async_trait::async_trait; use collab_database::fields::Field; use collab_database::rows::{new_cell_builder, Cell, Cells, Row, RowDetail}; use serde::{Deserialize, Serialize}; @@ -8,17 +9,15 @@ use flowy_error::FlowyResult; use crate::entities::{ FieldType, GroupPB, GroupRowsNotificationPB, InsertedGroupPB, InsertedRowPB, RowMetaPB, - URLCellDataPB, }; use crate::services::cell::insert_url_cell; -use crate::services::field::{URLCellData, URLCellDataParser, URLTypeOption}; +use crate::services::field::{TypeOption, URLCellData, URLCellDataParser, URLTypeOption}; use crate::services::group::action::GroupCustomize; use crate::services::group::configuration::GroupContext; -use crate::services::group::controller::{ - BaseGroupController, GroupController, GroupsBuilder, MoveGroupRowContext, -}; +use crate::services::group::controller::{BaseGroupController, GroupController}; use crate::services::group::{ make_no_status_group, move_group_row, GeneratedGroupConfig, GeneratedGroups, Group, + GroupOperationInterceptor, GroupTypeOptionCellOperation, GroupsBuilder, MoveGroupRowContext, }; #[derive(Default, Serialize, Deserialize)] @@ -26,13 +25,18 @@ pub struct URLGroupConfiguration { pub hide_empty: bool, } -pub type URLGroupController = - BaseGroupController<URLGroupConfiguration, URLTypeOption, URLGroupGenerator, URLCellDataParser>; +pub type URLGroupController = BaseGroupController< + URLGroupConfiguration, + URLTypeOption, + URLGroupGenerator, + URLCellDataParser, + URLGroupOperationInterceptorImpl, +>; pub type URLGroupContext = GroupContext<URLGroupConfiguration>; impl GroupCustomize for URLGroupController { - type CellData = URLCellDataPB; + type GroupTypeOption = URLTypeOption; fn placeholder_cell(&self) -> Option<Cell> { Some( @@ -42,15 +46,19 @@ impl GroupCustomize for URLGroupController { ) } - fn can_group(&self, content: &str, cell_data: &Self::CellData) -> bool { - cell_data.content == content + fn can_group( + &self, + content: &str, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellData, + ) -> bool { + cell_data.data == content } fn create_or_delete_group_when_cell_changed( &mut self, - row_detail: &RowDetail, - _old_cell_data: Option<&Self::CellData>, - _cell_data: &Self::CellData, + _row_detail: &RowDetail, + _old_cell_data: Option<&<Self::GroupTypeOption as TypeOption>::CellProtobufType>, + _cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, ) -> FlowyResult<(Option<InsertedGroupPB>, Option<GroupPB>)> { // Just return if the group with this url already exists let mut inserted_group = None; @@ -58,7 +66,7 @@ impl GroupCustomize for URLGroupController { let cell_data: URLCellData = _cell_data.clone().into(); let group = make_group_from_url_cell(&cell_data); let mut new_group = self.context.add_new_group(group)?; - new_group.group.rows.push(RowMetaPB::from(row_detail)); + new_group.group.rows.push(RowMetaPB::from(_row_detail)); inserted_group = Some(new_group); } @@ -90,7 +98,7 @@ impl GroupCustomize for URLGroupController { fn add_or_remove_row_when_cell_changed( &mut self, row_detail: &RowDetail, - cell_data: &Self::CellData, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, ) -> Vec<GroupRowsNotificationPB> { let mut changesets = vec![]; self.context.iter_mut_status_groups(|group| { @@ -116,7 +124,11 @@ impl GroupCustomize for URLGroupController { changesets } - fn delete_row(&mut self, row: &Row, _cell_data: &Self::CellData) -> Vec<GroupRowsNotificationPB> { + fn delete_row( + &mut self, + row: &Row, + cell_data: &<Self::GroupTypeOption as TypeOption>::CellData, + ) -> (Option<GroupPB>, Vec<GroupRowsNotificationPB>) { let mut changesets = vec![]; self.context.iter_mut_groups(|group| { let mut changeset = GroupRowsNotificationPB::new(group.id.clone()); @@ -129,12 +141,23 @@ impl GroupCustomize for URLGroupController { changesets.push(changeset); } }); - changesets + + let deleted_group = match self.context.get_group(&cell_data.data) { + Some((_, group)) if group.rows.len() == 1 => Some(group.clone()), + _ => None, + }; + + let deleted_group = deleted_group.map(|group| { + let _ = self.context.delete_group(&group.id); + group.into() + }); + + (deleted_group, changesets) } fn move_row( &mut self, - _cell_data: &Self::CellData, + _cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, mut context: MoveGroupRowContext, ) -> Vec<GroupRowsNotificationPB> { let mut group_changeset = vec![]; @@ -145,11 +168,10 @@ impl GroupCustomize for URLGroupController { }); group_changeset } - fn delete_group_when_move_row( &mut self, _row: &Row, - _cell_data: &Self::CellData, + _cell_data: &<Self::GroupTypeOption as TypeOption>::CellProtobufType, ) -> Option<GroupPB> { let mut deleted_group = None; if let Some((_, group)) = self.context.get_group(&_cell_data.content) { @@ -167,7 +189,7 @@ impl GroupCustomize for URLGroupController { } impl GroupController for URLGroupController { - fn did_update_field_type_option(&mut self, _field: &Arc<Field>) {} + fn did_update_field_type_option(&mut self, _field: &Field) {} fn will_create_row(&mut self, cells: &mut Cells, field: &Field, group_id: &str) { match self.context.get_group(group_id) { @@ -178,26 +200,21 @@ impl GroupController for URLGroupController { }, } } - - fn did_create_row(&mut self, row_detail: &RowDetail, group_id: &str) { - if let Some(group) = self.context.get_mut_group(group_id) { - group.add_row(row_detail.clone()) - } - } } pub struct URLGroupGenerator(); +#[async_trait] impl GroupsBuilder for URLGroupGenerator { type Context = URLGroupContext; - type TypeOptionType = URLTypeOption; + type GroupTypeOption = URLTypeOption; - fn build( + async fn build( field: &Field, context: &Self::Context, - _type_option: &Option<Self::TypeOptionType>, + _type_option: &Self::GroupTypeOption, ) -> GeneratedGroups { // Read all the cells for the grouping field - let cells = futures::executor::block_on(context.get_all_cells()); + let cells = context.get_all_cells().await; // Generate the groups let group_configs = cells @@ -223,3 +240,13 @@ fn make_group_from_url_cell(cell: &URLCellData) -> Group { let group_name = cell.data.clone(); Group::new(group_id, group_name) } + +pub struct URLGroupOperationInterceptorImpl { + #[allow(dead_code)] + pub(crate) cell_writer: Arc<dyn GroupTypeOptionCellOperation>, +} + +#[async_trait::async_trait] +impl GroupOperationInterceptor for URLGroupOperationInterceptorImpl { + type GroupTypeOption = URLTypeOption; +} diff --git a/frontend/rust-lib/flowy-database2/src/services/group/entities.rs b/frontend/rust-lib/flowy-database2/src/services/group/entities.rs index 72c35c91b9bb..253c12bac931 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/entities.rs @@ -12,15 +12,16 @@ pub struct GroupSetting { pub field_type: i64, pub groups: Vec<Group>, pub content: String, - pub hide_ungrouped: bool, } -pub struct GroupSettingChangeset { - pub hide_ungrouped: Option<bool>, +pub struct GroupChangesets { + pub changesets: Vec<GroupChangeset>, } -pub struct GroupChangesets { - pub update_groups: Vec<GroupChangeset>, +impl From<Vec<GroupChangeset>> for GroupChangesets { + fn from(changesets: Vec<GroupChangeset>) -> Self { + Self { changesets } + } } #[derive(Clone, Default, Debug)] @@ -32,14 +33,13 @@ pub struct GroupChangeset { } impl GroupSetting { - pub fn new(field_id: String, field_type: i64, content: String, hide_ungrouped: bool) -> Self { + pub fn new(field_id: String, field_type: i64, content: String) -> Self { Self { id: gen_database_group_id(), field_id, field_type, groups: vec![], content, - hide_ungrouped, } } } @@ -49,7 +49,6 @@ const FIELD_ID: &str = "field_id"; const FIELD_TYPE: &str = "ty"; const GROUPS: &str = "groups"; const CONTENT: &str = "content"; -const HIDE_UNGROUPED: &str = "hide_ungrouped"; impl TryFrom<GroupSettingMap> for GroupSetting { type Error = anyhow::Error; @@ -59,9 +58,8 @@ impl TryFrom<GroupSettingMap> for GroupSetting { value.get_str_value(GROUP_ID), value.get_str_value(FIELD_ID), value.get_i64_value(FIELD_TYPE), - value.get_bool_value(HIDE_UNGROUPED), ) { - (Some(id), Some(field_id), Some(field_type), Some(hide_ungrouped)) => { + (Some(id), Some(field_id), Some(field_type)) => { let content = value.get_str_value(CONTENT).unwrap_or_default(); let groups = value.try_get_array(GROUPS); Ok(Self { @@ -70,7 +68,6 @@ impl TryFrom<GroupSettingMap> for GroupSetting { field_type, groups, content, - hide_ungrouped, }) }, _ => { @@ -88,7 +85,6 @@ impl From<GroupSetting> for GroupSettingMap { .insert_i64_value(FIELD_TYPE, setting.field_type) .insert_maps(GROUPS, setting.groups) .insert_str_value(CONTENT, setting.content) - .insert_bool_value(HIDE_UNGROUPED, setting.hide_ungrouped) .build() } } @@ -152,13 +148,19 @@ pub struct GroupData { } impl GroupData { - pub fn new(id: String, field_id: String, name: String, filter_content: String) -> Self { + pub fn new( + id: String, + field_id: String, + name: String, + filter_content: String, + is_visible: bool, + ) -> Self { let is_default = id == field_id; Self { id, field_id, is_default, - is_visible: true, + is_visible, name, rows: vec![], filter_content, diff --git a/frontend/rust-lib/flowy-database2/src/services/group/group_builder.rs b/frontend/rust-lib/flowy-database2/src/services/group/group_builder.rs index bc610302be6b..c221c7fdaf1e 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/group_builder.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/group_builder.rs @@ -1,19 +1,82 @@ +use std::collections::HashMap; use std::sync::Arc; +use async_trait::async_trait; use collab_database::fields::Field; -use collab_database::rows::RowDetail; +use collab_database::rows::{Cell, RowDetail, RowId}; use collab_database::views::DatabaseLayout; use flowy_error::FlowyResult; use crate::entities::FieldType; +use crate::services::field::TypeOption; use crate::services::group::{ - CheckboxGroupContext, CheckboxGroupController, DateGroupContext, DateGroupController, - DefaultGroupController, Group, GroupController, GroupSetting, GroupSettingReader, - GroupSettingWriter, MultiSelectGroupController, MultiSelectOptionGroupContext, - SingleSelectGroupController, SingleSelectOptionGroupContext, URLGroupContext, URLGroupController, + CheckboxGroupContext, CheckboxGroupController, CheckboxGroupOperationInterceptorImpl, + DateGroupContext, DateGroupController, DateGroupOperationInterceptorImpl, DefaultGroupController, + Group, GroupController, GroupSetting, GroupSettingReader, GroupSettingWriter, + GroupTypeOptionCellOperation, MultiSelectGroupController, + MultiSelectGroupOperationInterceptorImpl, MultiSelectOptionGroupContext, + SingleSelectGroupController, SingleSelectGroupOperationInterceptorImpl, + SingleSelectOptionGroupContext, URLGroupContext, URLGroupController, + URLGroupOperationInterceptorImpl, }; +/// The [GroupsBuilder] trait is used to generate the groups for different [FieldType] +#[async_trait] +pub trait GroupsBuilder: Send + Sync + 'static { + type Context; + type GroupTypeOption: TypeOption; + + async fn build( + field: &Field, + context: &Self::Context, + type_option: &Self::GroupTypeOption, + ) -> GeneratedGroups; +} + +pub struct GeneratedGroups { + pub no_status_group: Option<Group>, + pub group_configs: Vec<GeneratedGroupConfig>, +} + +pub struct GeneratedGroupConfig { + pub group: Group, + pub filter_content: String, +} + +pub struct MoveGroupRowContext<'a> { + pub row_detail: &'a RowDetail, + pub row_changeset: &'a mut RowChangeset, + pub field: &'a Field, + pub to_group_id: &'a str, + pub to_row_id: Option<RowId>, +} + +#[derive(Debug, Clone)] +pub struct RowChangeset { + pub row_id: RowId, + pub height: Option<i32>, + pub visibility: Option<bool>, + // Contains the key/value changes represents as the update of the cells. For example, + // if there is one cell was changed, then the `cell_by_field_id` will only have one key/value. + pub cell_by_field_id: HashMap<String, Cell>, +} + +impl RowChangeset { + pub fn new(row_id: RowId) -> Self { + Self { + row_id, + height: None, + visibility: None, + cell_by_field_id: Default::default(), + } + } + + pub fn is_empty(&self) -> bool { + self.height.is_none() && self.visibility.is_none() && self.cell_by_field_id.is_empty() + } +} + /// Returns a group controller. /// /// Each view can be grouped by one field, each field has its own group controller. @@ -31,16 +94,18 @@ use crate::services::group::{ fields(grouping_field_id=%grouping_field.id, grouping_field_type) err )] -pub async fn make_group_controller<R, W>( +pub async fn make_group_controller<R, W, TW>( view_id: String, grouping_field: Arc<Field>, row_details: Vec<Arc<RowDetail>>, setting_reader: R, setting_writer: W, + type_option_cell_writer: TW, ) -> FlowyResult<Box<dyn GroupController>> where R: GroupSettingReader, W: GroupSettingWriter, + TW: GroupTypeOptionCellOperation, { let grouping_field_type = FieldType::from(grouping_field.field_type); tracing::Span::current().record("grouping_field", &grouping_field_type.default_name()); @@ -48,6 +113,7 @@ where let mut group_controller: Box<dyn GroupController>; let configuration_reader = Arc::new(setting_reader); let configuration_writer = Arc::new(setting_writer); + let type_option_cell_writer = Arc::new(type_option_cell_writer); match grouping_field_type { FieldType::SingleSelect => { @@ -58,7 +124,10 @@ where configuration_writer, ) .await?; - let controller = SingleSelectGroupController::new(&grouping_field, configuration).await?; + let operation_interceptor = SingleSelectGroupOperationInterceptorImpl; + let controller = + SingleSelectGroupController::new(&grouping_field, configuration, operation_interceptor) + .await?; group_controller = Box::new(controller); }, FieldType::MultiSelect => { @@ -69,7 +138,10 @@ where configuration_writer, ) .await?; - let controller = MultiSelectGroupController::new(&grouping_field, configuration).await?; + let operation_interceptor = MultiSelectGroupOperationInterceptorImpl; + let controller = + MultiSelectGroupController::new(&grouping_field, configuration, operation_interceptor) + .await?; group_controller = Box::new(controller); }, FieldType::Checkbox => { @@ -80,7 +152,9 @@ where configuration_writer, ) .await?; - let controller = CheckboxGroupController::new(&grouping_field, configuration).await?; + let operation_interceptor = CheckboxGroupOperationInterceptorImpl {}; + let controller = + CheckboxGroupController::new(&grouping_field, configuration, operation_interceptor).await?; group_controller = Box::new(controller); }, FieldType::URL => { @@ -91,7 +165,11 @@ where configuration_writer, ) .await?; - let controller = URLGroupController::new(&grouping_field, configuration).await?; + let operation_interceptor = URLGroupOperationInterceptorImpl { + cell_writer: type_option_cell_writer, + }; + let controller = + URLGroupController::new(&grouping_field, configuration, operation_interceptor).await?; group_controller = Box::new(controller); }, FieldType::DateTime => { @@ -102,7 +180,9 @@ where configuration_writer, ) .await?; - let controller = DateGroupController::new(&grouping_field, configuration).await?; + let operation_interceptor = DateGroupOperationInterceptorImpl {}; + let controller = + DateGroupController::new(&grouping_field, configuration, operation_interceptor).await?; group_controller = Box::new(controller); }, _ => { @@ -154,7 +234,7 @@ pub fn find_new_grouping_field( /// pub fn default_group_setting(field: &Field) -> GroupSetting { let field_id = field.id.clone(); - GroupSetting::new(field_id, field.field_type, "".to_owned(), false) + GroupSetting::new(field_id, field.field_type, "".to_owned()) } pub fn make_no_status_group(field: &Field) -> Group { diff --git a/frontend/rust-lib/flowy-database2/src/services/group/mod.rs b/frontend/rust-lib/flowy-database2/src/services/group/mod.rs index fd11447bb801..c9f9e91b655a 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/mod.rs @@ -8,5 +8,5 @@ mod group_builder; pub(crate) use configuration::*; pub(crate) use controller::*; pub(crate) use controller_impls::*; -pub use entities::*; +pub(crate) use entities::*; pub(crate) use group_builder::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/setting/entities.rs b/frontend/rust-lib/flowy-database2/src/services/setting/entities.rs index 808d2f7a7529..7cfe09372545 100644 --- a/frontend/rust-lib/flowy-database2/src/services/setting/entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/setting/entities.rs @@ -89,3 +89,37 @@ impl CalendarLayout { pub const DEFAULT_FIRST_DAY_OF_WEEK: i32 = 0; pub const DEFAULT_SHOW_WEEKENDS: bool = true; pub const DEFAULT_SHOW_WEEK_NUMBERS: bool = true; + +#[derive(Debug, Clone, Default)] +pub struct BoardLayoutSetting { + pub hide_ungrouped_column: bool, + pub collapse_hidden_groups: bool, +} + +impl BoardLayoutSetting { + pub fn new() -> Self { + Self::default() + } +} + +impl From<LayoutSetting> for BoardLayoutSetting { + fn from(setting: LayoutSetting) -> Self { + Self { + hide_ungrouped_column: setting + .get_bool_value("hide_ungrouped_column") + .unwrap_or_default(), + collapse_hidden_groups: setting + .get_bool_value("collapse_hidden_groups") + .unwrap_or_default(), + } + } +} + +impl From<BoardLayoutSetting> for LayoutSetting { + fn from(setting: BoardLayoutSetting) -> Self { + LayoutSettingBuilder::new() + .insert_bool_value("hide_ungrouped_column", setting.hide_ungrouped_column) + .insert_bool_value("collapse_hidden_groups", setting.collapse_hidden_groups) + .build() + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/share/csv/import.rs b/frontend/rust-lib/flowy-database2/src/services/share/csv/import.rs index ee16cdc94d87..037c53597d75 100644 --- a/frontend/rust-lib/flowy-database2/src/services/share/csv/import.rs +++ b/frontend/rust-lib/flowy-database2/src/services/share/csv/import.rs @@ -9,7 +9,7 @@ use flowy_error::{FlowyError, FlowyResult}; use crate::entities::FieldType; use crate::services::field::{default_type_option_data_from_type, CELL_DATA}; -use crate::services::field_settings::DatabaseFieldSettingsMapBuilder; +use crate::services::field_settings::default_field_settings_for_fields; use crate::services::share::csv::CSVFormat; #[derive(Default)] @@ -97,8 +97,7 @@ fn database_from_fields_and_rows( }) .collect::<Vec<Field>>(); - let field_settings = - DatabaseFieldSettingsMapBuilder::new(fields.clone(), DatabaseLayout::Grid).build(); + let field_settings = default_field_settings_for_fields(&fields, DatabaseLayout::Grid); let created_rows = rows .iter() diff --git a/frontend/rust-lib/flowy-database2/src/services/sort/controller.rs b/frontend/rust-lib/flowy-database2/src/services/sort/controller.rs index 3540ba9c2328..fe4cf2d55c88 100644 --- a/frontend/rust-lib/flowy-database2/src/services/sort/controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/sort/controller.rs @@ -26,7 +26,7 @@ pub trait SortDelegate: Send + Sync { fn get_sort(&self, view_id: &str, sort_id: &str) -> Fut<Option<Arc<Sort>>>; /// Returns all the rows after applying grid's filter fn get_rows(&self, view_id: &str) -> Fut<Vec<Arc<RowDetail>>>; - fn get_field(&self, field_id: &str) -> Fut<Option<Arc<Field>>>; + fn get_field(&self, field_id: &str) -> Option<Field>; fn get_fields(&self, view_id: &str, field_ids: Option<Vec<String>>) -> Fut<Vec<Arc<Field>>>; } diff --git a/frontend/rust-lib/flowy-database2/src/template.rs b/frontend/rust-lib/flowy-database2/src/template.rs index 4f819ac43aa1..bc3f23bc056d 100644 --- a/frontend/rust-lib/flowy-database2/src/template.rs +++ b/frontend/rust-lib/flowy-database2/src/template.rs @@ -7,8 +7,8 @@ use crate::services::cell::{insert_select_option_cell, insert_text_cell}; use crate::services::field::{ FieldBuilder, SelectOption, SelectOptionColor, SingleSelectTypeOption, }; -use crate::services::field_settings::DatabaseFieldSettingsMapBuilder; -use crate::services::setting::CalendarLayoutSetting; +use crate::services::field_settings::default_field_settings_for_fields; +use crate::services::setting::{BoardLayoutSetting, CalendarLayoutSetting}; pub fn make_default_grid(view_id: &str, name: &str) -> CreateDatabaseParams { let text_field = FieldBuilder::from_field_type(FieldType::RichText) @@ -29,8 +29,7 @@ pub fn make_default_grid(view_id: &str, name: &str) -> CreateDatabaseParams { let fields = vec![text_field, single_select, checkbox_field]; - let field_settings = - DatabaseFieldSettingsMapBuilder::new(fields.clone(), DatabaseLayout::Grid).build(); + let field_settings = default_field_settings_for_fields(&fields, DatabaseLayout::Grid); CreateDatabaseParams { database_id: gen_database_id(), @@ -90,15 +89,17 @@ pub fn make_default_board(view_id: &str, name: &str) -> CreateDatabaseParams { let fields = vec![text_field, single_select]; - let field_settings = - DatabaseFieldSettingsMapBuilder::new(fields.clone(), DatabaseLayout::Board).build(); + let field_settings = default_field_settings_for_fields(&fields, DatabaseLayout::Board); + + let mut layout_settings = LayoutSettings::default(); + layout_settings.insert(DatabaseLayout::Board, BoardLayoutSetting::new().into()); CreateDatabaseParams { database_id: gen_database_id(), view_id: view_id.to_string(), name: name.to_string(), layout: DatabaseLayout::Board, - layout_settings: Default::default(), + layout_settings, filters: vec![], groups: vec![], sorts: vec![], @@ -131,8 +132,7 @@ pub fn make_default_calendar(view_id: &str, name: &str) -> CreateDatabaseParams let fields = vec![text_field, date_field, multi_select_field]; - let field_settings = - DatabaseFieldSettingsMapBuilder::new(fields.clone(), DatabaseLayout::Calendar).build(); + let field_settings = default_field_settings_for_fields(&fields, DatabaseLayout::Calendar); let mut layout_settings = LayoutSettings::default(); layout_settings.insert( diff --git a/frontend/rust-lib/flowy-database2/tests/database/database_editor.rs b/frontend/rust-lib/flowy-database2/tests/database/database_editor.rs index 852b7ae1e856..0a52c1538e1b 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/database_editor.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/database_editor.rs @@ -37,7 +37,7 @@ pub struct DatabaseEditorTest { impl DatabaseEditorTest { pub async fn new_grid() -> Self { - let sdk = EventIntegrationTest::new(); + let sdk = EventIntegrationTest::new().await; let _ = sdk.init_anon_user().await; let params = make_test_grid(); @@ -46,7 +46,7 @@ impl DatabaseEditorTest { } pub async fn new_no_date_grid() -> Self { - let sdk = EventIntegrationTest::new(); + let sdk = EventIntegrationTest::new().await; let _ = sdk.init_anon_user().await; let params = make_no_date_test_grid(); @@ -55,7 +55,7 @@ impl DatabaseEditorTest { } pub async fn new_board() -> Self { - let sdk = EventIntegrationTest::new(); + let sdk = EventIntegrationTest::new().await; let _ = sdk.init_anon_user().await; let params = make_test_board(); @@ -64,7 +64,7 @@ impl DatabaseEditorTest { } pub async fn new_calendar() -> Self { - let sdk = EventIntegrationTest::new(); + let sdk = EventIntegrationTest::new().await; let _ = sdk.init_anon_user().await; let params = make_test_calendar(); diff --git a/frontend/rust-lib/flowy-database2/tests/database/field_settings_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/field_settings_test/script.rs index 492221184a23..e5251bd1213f 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/field_settings_test/script.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/field_settings_test/script.rs @@ -1,4 +1,3 @@ -use collab_database::views::DatabaseLayout; use flowy_database2::entities::FieldVisibility; use flowy_database2::services::field_settings::FieldSettingsChangesetParams; @@ -8,16 +7,17 @@ use crate::database::database_editor::DatabaseEditorTest; pub enum FieldSettingsScript { AssertFieldSettings { field_ids: Vec<String>, - layout_ty: DatabaseLayout, visibility: FieldVisibility, + width: i32, }, AssertAllFieldSettings { - layout_ty: DatabaseLayout, visibility: FieldVisibility, + width: i32, }, UpdateFieldSettings { field_id: String, visibility: Option<FieldVisibility>, + width: Option<i32>, }, } @@ -51,41 +51,42 @@ impl FieldSettingsTest { match script { FieldSettingsScript::AssertFieldSettings { field_ids, - layout_ty, visibility, + width, } => { let field_settings = self .editor - .get_field_settings(&self.view_id, layout_ty, field_ids) + .get_field_settings(&self.view_id, field_ids) .await .unwrap(); for field_settings in field_settings.into_iter() { - assert_eq!(field_settings.visibility, visibility) + assert_eq!(field_settings.width, width); + assert_eq!(field_settings.visibility, visibility); } }, - FieldSettingsScript::AssertAllFieldSettings { - layout_ty, - visibility, - } => { + FieldSettingsScript::AssertAllFieldSettings { visibility, width } => { let field_settings = self .editor - .get_all_field_settings(&self.view_id, layout_ty) + .get_all_field_settings(&self.view_id) .await .unwrap(); for field_settings in field_settings.into_iter() { - assert_eq!(field_settings.visibility, visibility) + assert_eq!(field_settings.width, width); + assert_eq!(field_settings.visibility, visibility); } }, FieldSettingsScript::UpdateFieldSettings { field_id, visibility, + width, } => { let params = FieldSettingsChangesetParams { view_id: self.view_id.clone(), field_id, visibility, + width, }; let _ = self .editor diff --git a/frontend/rust-lib/flowy-database2/tests/database/field_settings_test/test.rs b/frontend/rust-lib/flowy-database2/tests/database/field_settings_test/test.rs index 749f1c9419ef..3c8963b8e667 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/field_settings_test/test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/field_settings_test/test.rs @@ -1,6 +1,6 @@ -use collab_database::views::DatabaseLayout; use flowy_database2::entities::FieldType; use flowy_database2::entities::FieldVisibility; +use flowy_database2::services::field_settings::DEFAULT_WIDTH; use crate::database::field_settings_test::script::FieldSettingsScript::*; use crate::database::field_settings_test::script::FieldSettingsTest; @@ -10,8 +10,8 @@ use crate::database::field_settings_test::script::FieldSettingsTest; async fn get_default_field_settings() { let mut test = FieldSettingsTest::new_grid().await; let scripts = vec![AssertAllFieldSettings { - layout_ty: DatabaseLayout::Grid, visibility: FieldVisibility::AlwaysShown, + width: DEFAULT_WIDTH, }]; test.run_scripts(scripts).await; @@ -26,13 +26,13 @@ async fn get_default_field_settings() { let scripts = vec![ AssertFieldSettings { field_ids: non_primary_field_ids.clone(), - layout_ty: DatabaseLayout::Board, visibility: FieldVisibility::HideWhenEmpty, + width: DEFAULT_WIDTH, }, AssertFieldSettings { field_ids: vec![primary_field_id.clone()], - layout_ty: DatabaseLayout::Board, visibility: FieldVisibility::AlwaysShown, + width: DEFAULT_WIDTH, }, ]; test.run_scripts(scripts).await; @@ -48,13 +48,13 @@ async fn get_default_field_settings() { let scripts = vec![ AssertFieldSettings { field_ids: non_primary_field_ids.clone(), - layout_ty: DatabaseLayout::Calendar, visibility: FieldVisibility::HideWhenEmpty, + width: DEFAULT_WIDTH, }, AssertFieldSettings { field_ids: vec![primary_field_id.clone()], - layout_ty: DatabaseLayout::Calendar, visibility: FieldVisibility::AlwaysShown, + width: DEFAULT_WIDTH, }, ]; test.run_scripts(scripts).await; @@ -75,21 +75,22 @@ async fn update_field_settings_test() { let scripts = vec![ AssertFieldSettings { field_ids: non_primary_field_ids, - layout_ty: DatabaseLayout::Board, visibility: FieldVisibility::HideWhenEmpty, + width: DEFAULT_WIDTH, }, AssertFieldSettings { field_ids: vec![primary_field_id.clone()], - layout_ty: DatabaseLayout::Board, visibility: FieldVisibility::AlwaysShown, + width: DEFAULT_WIDTH, }, UpdateFieldSettings { field_id: primary_field_id, visibility: Some(FieldVisibility::HideWhenEmpty), + width: None, }, AssertAllFieldSettings { - layout_ty: DatabaseLayout::Board, visibility: FieldVisibility::HideWhenEmpty, + width: DEFAULT_WIDTH, }, ]; test.run_scripts(scripts).await; diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/script.rs index de07820b6c43..423761d17652 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/script.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/script.rs @@ -14,6 +14,7 @@ use flowy_database2::entities::{CheckboxFilterConditionPB, CheckboxFilterPB, Che use flowy_database2::services::database_view::DatabaseViewChanged; use flowy_database2::services::field::SelectOption; use flowy_database2::services::filter::FilterType; +use lib_dispatch::prelude::af_spawn; use crate::database::database_editor::DatabaseEditorTest; @@ -278,7 +279,7 @@ impl DatabaseFilterTest { if change.is_none() {return;} let change = change.unwrap(); let mut receiver = self.recv.take().unwrap(); - tokio::spawn(async move { + af_spawn(async move { match tokio::time::timeout(Duration::from_secs(2), receiver.recv()).await { Ok(changed) => { match changed.unwrap() { DatabaseViewChanged::FilterNotification(notification) => { diff --git a/frontend/rust-lib/flowy-database2/tests/database/group_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/group_test/script.rs index 55ab2bc1d49b..330ecc9044ee 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/group_test/script.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/group_test/script.rs @@ -10,7 +10,6 @@ use flowy_database2::services::field::{ edit_single_select_type_option, SelectOption, SelectTypeOptionSharedAction, SingleSelectTypeOption, }; -use flowy_database2::services::group::GroupSettingChangeset; use lib_infra::util::timestamp; use crate::database::database_editor::DatabaseEditorTest; @@ -68,11 +67,8 @@ pub enum GroupScript { group_id: String, group_name: String, }, - AssertGroupConfiguration { - hide_ungrouped: bool, - }, - UpdateGroupConfiguration { - hide_ungrouped: Option<bool>, + CreateGroup { + name: String, }, } @@ -276,25 +272,11 @@ impl DatabaseGroupTest { assert_eq!(group_id, group.group_id, "group index: {}", group_index); assert_eq!(group_name, group.group_name, "group index: {}", group_index); }, - GroupScript::AssertGroupConfiguration { hide_ungrouped } => { - let group_configuration = self - .editor - .get_group_configuration_settings(&self.view_id) - .await - .unwrap(); - let group_configuration = group_configuration.get(0).unwrap(); - assert_eq!(group_configuration.hide_ungrouped, hide_ungrouped); - }, - GroupScript::UpdateGroupConfiguration { hide_ungrouped } => { - self - .editor - .update_group_configuration_setting( - &self.view_id, - GroupSettingChangeset { hide_ungrouped }, - ) - .await - .unwrap(); - }, + GroupScript::CreateGroup { name } => self + .editor + .create_group(&self.view_id, &name) + .await + .unwrap(), } } diff --git a/frontend/rust-lib/flowy-database2/tests/database/group_test/test.rs b/frontend/rust-lib/flowy-database2/tests/database/group_test/test.rs index fb37bbea6aac..119986b04bee 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/group_test/test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/group_test/test.rs @@ -28,23 +28,6 @@ async fn group_init_test() { test.run_scripts(scripts).await; } -// #[tokio::test] -// async fn group_configuration_setting_test() { -// let mut test = DatabaseGroupTest::new().await; -// let scripts = vec![ -// AssertGroupConfiguration { -// hide_ungrouped: false, -// }, -// UpdateGroupConfiguration { -// hide_ungrouped: Some(true), -// }, -// AssertGroupConfiguration { -// hide_ungrouped: true, -// }, -// ]; -// test.run_scripts(scripts).await; -// } - #[tokio::test] async fn group_move_row_test() { let mut test = DatabaseGroupTest::new().await; @@ -503,3 +486,19 @@ async fn group_group_by_other_field() { ]; test.run_scripts(scripts).await; } + +#[tokio::test] +async fn group_manual_create_new_group() { + let mut test = DatabaseGroupTest::new().await; + let new_group_name = "Resumed"; + let scripts = vec![ + AssertGroupCount(4), + CreateGroup { + name: new_group_name.to_string(), + }, + AssertGroupCount(5), + ]; + test.run_scripts(scripts).await; + let new_group = test.group_at_index(4).await; + assert_eq!(new_group.group_name, new_group_name); +} diff --git a/frontend/rust-lib/flowy-database2/tests/database/layout_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/layout_test/script.rs index 02103f81b7cd..6800a7e4db42 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/layout_test/script.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/layout_test/script.rs @@ -1,13 +1,15 @@ use collab_database::fields::Field; use collab_database::views::DatabaseLayout; -use flowy_database2::entities::FieldType; -use flowy_database2::services::setting::CalendarLayoutSetting; +use flowy_database2::entities::{FieldType, LayoutSettingChangeset, LayoutSettingParams}; +use flowy_database2::services::setting::{BoardLayoutSetting, CalendarLayoutSetting}; use crate::database::database_editor::DatabaseEditorTest; pub enum LayoutScript { + AssertBoardLayoutSetting { expected: BoardLayoutSetting }, AssertCalendarLayoutSetting { expected: CalendarLayoutSetting }, + UpdateBoardLayoutSetting { new_setting: BoardLayoutSetting }, AssertDefaultAllCalendarEvents, AssertAllCalendarEventsCount { expected: usize }, UpdateDatabaseLayout { layout: DatabaseLayout }, @@ -23,21 +25,39 @@ impl DatabaseLayoutTest { Self { database_test } } + pub async fn new_board() -> Self { + let database_test = DatabaseEditorTest::new_board().await; + Self { database_test } + } + pub async fn new_calendar() -> Self { let database_test = DatabaseEditorTest::new_calendar().await; Self { database_test } } + pub async fn get_first_date_field(&self) -> Field { + self.database_test.get_first_field(FieldType::DateTime) + } + + async fn get_layout_setting( + &self, + view_id: &str, + layout_ty: DatabaseLayout, + ) -> LayoutSettingParams { + self + .database_test + .editor + .get_layout_setting(view_id, layout_ty) + .await + .unwrap() + } + pub async fn run_scripts(&mut self, scripts: Vec<LayoutScript>) { for script in scripts { self.run_script(script).await; } } - pub async fn get_first_date_field(&self) -> Field { - self.database_test.get_first_field(FieldType::DateTime) - } - pub async fn run_script(&mut self, script: LayoutScript) { match script { LayoutScript::UpdateDatabaseLayout { layout } => { @@ -56,19 +76,27 @@ impl DatabaseLayoutTest { .await; assert_eq!(events.len(), expected); }, + LayoutScript::AssertBoardLayoutSetting { expected } => { + let view_id = self.database_test.view_id.clone(); + let layout_ty = DatabaseLayout::Board; + + let layout_settings = self.get_layout_setting(&view_id, layout_ty).await; + + assert!(layout_settings.calendar.is_none()); + assert_eq!( + layout_settings.board.unwrap().hide_ungrouped_column, + expected.hide_ungrouped_column + ); + }, LayoutScript::AssertCalendarLayoutSetting { expected } => { let view_id = self.database_test.view_id.clone(); let layout_ty = DatabaseLayout::Calendar; - let calendar_setting = self - .database_test - .editor - .get_layout_setting(&view_id, layout_ty) - .await - .unwrap() - .calendar - .unwrap(); + let layout_settings = self.get_layout_setting(&view_id, layout_ty).await; + assert!(layout_settings.board.is_none()); + + let calendar_setting = layout_settings.calendar.unwrap(); assert_eq!(calendar_setting.layout_ty, expected.layout_ty); assert_eq!( calendar_setting.first_day_of_week, @@ -76,6 +104,20 @@ impl DatabaseLayoutTest { ); assert_eq!(calendar_setting.show_weekends, expected.show_weekends); }, + LayoutScript::UpdateBoardLayoutSetting { new_setting } => { + let changeset = LayoutSettingChangeset { + view_id: self.database_test.view_id.clone(), + layout_type: DatabaseLayout::Board, + board: Some(new_setting), + calendar: None, + }; + self + .database_test + .editor + .set_layout_setting(&self.database_test.view_id, changeset) + .await + .unwrap() + }, LayoutScript::AssertDefaultAllCalendarEvents => { let events = self .database_test diff --git a/frontend/rust-lib/flowy-database2/tests/database/layout_test/test.rs b/frontend/rust-lib/flowy-database2/tests/database/layout_test/test.rs index a380e4b51186..41f2f88d0eef 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/layout_test/test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/layout_test/test.rs @@ -1,9 +1,32 @@ use collab_database::views::DatabaseLayout; +use flowy_database2::services::setting::BoardLayoutSetting; use flowy_database2::services::setting::CalendarLayoutSetting; use crate::database::layout_test::script::DatabaseLayoutTest; use crate::database::layout_test::script::LayoutScript::*; +#[tokio::test] +async fn board_layout_setting_test() { + let mut test = DatabaseLayoutTest::new_board().await; + let default_board_setting = BoardLayoutSetting::new(); + let new_board_setting = BoardLayoutSetting { + hide_ungrouped_column: true, + ..default_board_setting + }; + let scripts = vec![ + AssertBoardLayoutSetting { + expected: default_board_setting, + }, + UpdateBoardLayoutSetting { + new_setting: new_board_setting.clone(), + }, + AssertBoardLayoutSetting { + expected: new_board_setting, + }, + ]; + test.run_scripts(scripts).await; +} + #[tokio::test] async fn calendar_initial_layout_setting_test() { let mut test = DatabaseLayoutTest::new_calendar().await; diff --git a/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs b/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs index a6fd10cce7b9..1e21cb9474db 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs @@ -1,6 +1,7 @@ use collab_database::database::{gen_database_id, gen_database_view_id, gen_row_id, DatabaseData}; -use collab_database::views::{DatabaseLayout, DatabaseView}; -use flowy_database2::services::field_settings::DatabaseFieldSettingsMapBuilder; +use collab_database::views::{DatabaseLayout, DatabaseView, LayoutSetting, LayoutSettings}; +use flowy_database2::services::field_settings::default_field_settings_for_fields; +use flowy_database2::services::setting::BoardLayoutSetting; use strum::IntoEnumIterator; use flowy_database2::entities::FieldType; @@ -128,8 +129,9 @@ pub fn make_test_board() -> DatabaseData { } } - let field_settings = - DatabaseFieldSettingsMapBuilder::new(fields.clone(), DatabaseLayout::Board).build(); + let board_setting: LayoutSetting = BoardLayoutSetting::new().into(); + + let field_settings = default_field_settings_for_fields(&fields, DatabaseLayout::Board); // We have many assumptions base on the number of the rows, so do not change the number of the loop. for i in 0..5 { @@ -238,12 +240,15 @@ pub fn make_test_board() -> DatabaseData { rows.push(row); } + let mut layout_settings = LayoutSettings::new(); + layout_settings.insert(DatabaseLayout::Board, board_setting); + let view = DatabaseView { id: gen_database_view_id(), database_id: gen_database_id(), name: "".to_string(), layout: DatabaseLayout::Board, - layout_settings: Default::default(), + layout_settings, filters: vec![], group_settings: vec![], sorts: vec![], diff --git a/frontend/rust-lib/flowy-database2/tests/database/mock_data/calendar_mock_data.rs b/frontend/rust-lib/flowy-database2/tests/database/mock_data/calendar_mock_data.rs index c41433e591c6..7587c8ca4f2b 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/mock_data/calendar_mock_data.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/mock_data/calendar_mock_data.rs @@ -1,6 +1,6 @@ use collab_database::database::{gen_database_id, gen_database_view_id, gen_row_id, DatabaseData}; use collab_database::views::{DatabaseLayout, DatabaseView, LayoutSetting, LayoutSettings}; -use flowy_database2::services::field_settings::DatabaseFieldSettingsMapBuilder; +use flowy_database2::services::field_settings::default_field_settings_for_fields; use strum::IntoEnumIterator; use flowy_database2::entities::FieldType; @@ -40,8 +40,7 @@ pub fn make_test_calendar() -> DatabaseData { let calendar_setting: LayoutSetting = CalendarLayoutSetting::new(date_field_id).into(); - let field_settings = - DatabaseFieldSettingsMapBuilder::new(fields.clone(), DatabaseLayout::Calendar).build(); + let field_settings = default_field_settings_for_fields(&fields, DatabaseLayout::Calendar); for i in 0..5 { let mut row_builder = TestRowBuilder::new(gen_row_id(), &fields); diff --git a/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs b/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs index fcba0a73f21e..52790c2b2758 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs @@ -1,6 +1,6 @@ use collab_database::database::{gen_database_id, gen_database_view_id, gen_row_id, DatabaseData}; use collab_database::views::{DatabaseLayout, DatabaseView}; -use flowy_database2::services::field_settings::DatabaseFieldSettingsMapBuilder; +use flowy_database2::services::field_settings::default_field_settings_for_fields; use strum::IntoEnumIterator; use flowy_database2::entities::FieldType; @@ -131,8 +131,7 @@ pub fn make_test_grid() -> DatabaseData { } } - let field_settings = - DatabaseFieldSettingsMapBuilder::new(fields.clone(), DatabaseLayout::Grid).build(); + let field_settings = default_field_settings_for_fields(&fields, DatabaseLayout::Grid); for i in 0..7 { let mut row_builder = TestRowBuilder::new(gen_row_id(), &fields); @@ -297,8 +296,7 @@ pub fn make_no_date_test_grid() -> DatabaseData { } } - let field_settings = - DatabaseFieldSettingsMapBuilder::new(fields.clone(), DatabaseLayout::Grid).build(); + let field_settings = default_field_settings_for_fields(&fields, DatabaseLayout::Grid); for i in 0..3 { let mut row_builder = TestRowBuilder::new(gen_row_id(), &fields); diff --git a/frontend/rust-lib/flowy-date/Cargo.toml b/frontend/rust-lib/flowy-date/Cargo.toml index 6211fd9bb7fb..61599c478710 100644 --- a/frontend/rust-lib/flowy-date/Cargo.toml +++ b/frontend/rust-lib/flowy-date/Cargo.toml @@ -9,12 +9,12 @@ edition = "2021" lib-dispatch = { path = "../lib-dispatch" } flowy-error = { path = "../flowy-error" } flowy-derive = { path = "../../../shared-lib/flowy-derive" } -protobuf = { version = "2.28.0" } -bytes = { version = "1.4" } +protobuf.workspace = true +bytes.workspace = true strum_macros = "0.21" -tracing = { version = "0.1" } +tracing.workspace = true date_time_parser = { version = "0.2.0" } -chrono = { version = "0.4.26" } +chrono.workspace = true fancy-regex = { version = "0.11.0" } [features] diff --git a/frontend/rust-lib/flowy-document-deps/Cargo.toml b/frontend/rust-lib/flowy-document-deps/Cargo.toml index 9c926cea7693..d4b04ca2b15d 100644 --- a/frontend/rust-lib/flowy-document-deps/Cargo.toml +++ b/frontend/rust-lib/flowy-document-deps/Cargo.toml @@ -9,4 +9,4 @@ edition = "2021" lib-infra = { path = "../../../shared-lib/lib-infra" } flowy-error = { workspace = true } collab-document = { version = "0.1.0" } -anyhow = "1.0.71" \ No newline at end of file +anyhow.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document-deps/src/cloud.rs b/frontend/rust-lib/flowy-document-deps/src/cloud.rs index 0a17113b7a66..59ee90206ea0 100644 --- a/frontend/rust-lib/flowy-document-deps/src/cloud.rs +++ b/frontend/rust-lib/flowy-document-deps/src/cloud.rs @@ -1,6 +1,7 @@ use anyhow::Error; pub use collab_document::blocks::DocumentData; +use flowy_error::FlowyError; use lib_infra::future::FutureResult; /// A trait for document cloud service. @@ -11,7 +12,7 @@ pub trait DocumentCloudService: Send + Sync + 'static { &self, document_id: &str, workspace_id: &str, - ) -> FutureResult<Vec<Vec<u8>>, Error>; + ) -> FutureResult<Vec<Vec<u8>>, FlowyError>; fn get_document_snapshots( &self, diff --git a/frontend/rust-lib/flowy-document2/Cargo.toml b/frontend/rust-lib/flowy-document2/Cargo.toml index 1bf274ad6d59..078903d1c3b6 100644 --- a/frontend/rust-lib/flowy-document2/Cargo.toml +++ b/frontend/rust-lib/flowy-document2/Cargo.toml @@ -18,21 +18,23 @@ flowy-notification = { workspace = true } flowy-error = { path = "../flowy-error", features = ["impl_from_serde", "impl_from_sqlite", "impl_from_dispatch_error", "impl_from_collab"] } lib-dispatch = { workspace = true } lib-infra = { path = "../../../shared-lib/lib-infra" } - -protobuf = {version = "2.28.0"} -bytes = { version = "1.5" } +validator = "0.16.0" +protobuf.workspace = true +bytes.workspace = true nanoid = "0.4.0" -parking_lot = "0.12.1" +parking_lot.workspace = true strum_macros = "0.21" -serde = { version = "1.0", features = ["derive"] } -serde_json = {version = "1.0"} -tracing = { version = "0.1", features = ["log"] } -tokio = { version = "1.26", features = ["full"] } -anyhow = "1.0" +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true +tokio = { workspace = true, features = ["full"] } +anyhow.workspace = true indexmap = {version = "1.9.2", features = ["serde"]} -uuid = { version = "1.3.3", features = ["v4"] } -futures = "0.3.26" -tokio-stream = { version = "0.1.14", features = ["sync"] } +uuid.workspace = true +futures.workspace = true +tokio-stream = { workspace = true, features = ["sync"] } +scraper = "0.18.0" +lru.workspace = true [dev-dependencies] tempfile = "3.4.0" diff --git a/frontend/rust-lib/flowy-document2/Flowy.toml b/frontend/rust-lib/flowy-document2/Flowy.toml index a48035cb147c..6ef51c220dd8 100644 --- a/frontend/rust-lib/flowy-document2/Flowy.toml +++ b/frontend/rust-lib/flowy-document2/Flowy.toml @@ -1,3 +1,3 @@ # Check out the FlowyConfig (located in flowy_toml.rs) for more details. -proto_input = ["src/event_map.rs", "src/entities.rs", "src/notification.rs"] +proto_input = ["src/event_map.rs", "src/entities.rs", "src/notification.rs", "src/parser/parser_entities.rs"] event_files = ["src/event_map.rs"] \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/src/document.rs b/frontend/rust-lib/flowy-document2/src/document.rs index bfa90980a742..2f56b28bdee6 100644 --- a/frontend/rust-lib/flowy-document2/src/document.rs +++ b/frontend/rust-lib/flowy-document2/src/document.rs @@ -9,6 +9,7 @@ use futures::StreamExt; use parking_lot::Mutex; use flowy_error::FlowyResult; +use lib_dispatch::prelude::af_spawn; use crate::entities::{DocEventPB, DocumentSnapshotStatePB, DocumentSyncStatePB}; use crate::notification::{send_notification, DocumentNotification}; @@ -61,7 +62,7 @@ fn subscribe_document_changed(doc_id: &str, document: &MutexDocument) { fn subscribe_document_snapshot_state(collab: &Arc<MutexCollab>) { let document_id = collab.lock().object_id.clone(); let mut snapshot_state = collab.lock().subscribe_snapshot_state(); - tokio::spawn(async move { + af_spawn(async move { while let Some(snapshot_state) = snapshot_state.next().await { if let Some(new_snapshot_id) = snapshot_state.snapshot_id() { tracing::debug!("Did create document remote snapshot: {}", new_snapshot_id); @@ -79,7 +80,7 @@ fn subscribe_document_snapshot_state(collab: &Arc<MutexCollab>) { fn subscribe_document_sync_state(collab: &Arc<MutexCollab>) { let document_id = collab.lock().object_id.clone(); let mut sync_state_stream = collab.lock().subscribe_sync_state(); - tokio::spawn(async move { + af_spawn(async move { while let Some(sync_state) = sync_state_stream.next().await { send_notification( &document_id, diff --git a/frontend/rust-lib/flowy-document2/src/entities.rs b/frontend/rust-lib/flowy-document2/src/entities.rs index 52efbed21b63..8e3d68ef6d55 100644 --- a/frontend/rust-lib/flowy-document2/src/entities.rs +++ b/frontend/rust-lib/flowy-document2/src/entities.rs @@ -319,6 +319,7 @@ pub struct ExportDataPB { #[pb(index = 2)] pub export_type: ExportType, } + #[derive(PartialEq, Eq, Debug, ProtoBuf_Enum, Clone, Default)] pub enum ConvertType { #[default] @@ -337,6 +338,7 @@ impl From<i32> for ConvertType { } } +/// for convert data to document /// for the json type /// the data is the json string #[derive(Default, ProtoBuf, Debug)] diff --git a/frontend/rust-lib/flowy-document2/src/event_handler.rs b/frontend/rust-lib/flowy-document2/src/event_handler.rs index c3b10859d03d..4f1d3bb700f2 100644 --- a/frontend/rust-lib/flowy-document2/src/event_handler.rs +++ b/frontend/rust-lib/flowy-document2/src/event_handler.rs @@ -12,9 +12,18 @@ use collab_document::blocks::{ }; use flowy_error::{FlowyError, FlowyResult}; -use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; +use lib_dispatch::prelude::{ + data_result_ok, AFPluginData, AFPluginDataValidator, AFPluginState, DataResult, +}; use crate::entities::*; +use crate::parser::document_data_parser::DocumentDataParser; +use crate::parser::parser_entities::{ + ConvertDataToJsonParams, ConvertDataToJsonPayloadPB, ConvertDataToJsonResponsePB, + ConvertDocumentParams, ConvertDocumentPayloadPB, ConvertDocumentResponsePB, +}; + +use crate::parser::external::parser::ExternalDataToNestedJSONParser; use crate::{manager::DocumentManager, parser::json::parser::JsonToDocumentParser}; fn upgrade_document( @@ -303,3 +312,102 @@ impl From<(&Vec<BlockEvent>, bool)> for DocEventPB { } } } + +/// Handler for converting a document to a JSON string, HTML string, or plain text string. +/// +/// ConvertDocumentPayloadPB is the input of this event. +/// ConvertDocumentResponsePB is the output of this event. +/// +/// # Examples +/// +/// Basic usage: +/// +/// ```txt +/// // document: [{ "block_id": "1", "type": "paragraph", "data": {"delta": [{ "insert": "Hello World!" }] } }, { "block_id": "2", "type": "paragraph", "data": {"delta": [{ "insert": "Hello World!" }] } +/// let test = DocumentEventTest::new().await; +/// let view = test.create_document().await; +/// let payload = ConvertDocumentPayloadPB { +/// document_id: view.id, +/// range: Some(RangePB { +/// start: SelectionPB { +/// block_id: "1".to_string(), +/// index: 0, +/// length: 5, +/// }, +/// end: SelectionPB { +/// block_id: "2".to_string(), +/// index: 5, +/// length: 7, +/// } +/// }), +/// parse_types: ParseTypePB { +/// json: true, +/// text: true, +/// html: true, +/// }, +/// }; +/// let result = test.convert_document(payload).await; +/// assert_eq!(result.json, Some("[{ \"block_id\": \"1\", \"type\": \"paragraph\", \"data\": {\"delta\": [{ \"insert\": \"Hello\" }] } }, { \"block_id\": \"2\", \"type\": \"paragraph\", \"data\": {\"delta\": [{ \"insert\": \" World!\" }] } }".to_string())); +/// assert_eq!(result.text, Some("Hello\n World!".to_string())); +/// assert_eq!(result.html, Some("<p>Hello</p><p> World!</p>".to_string())); +/// ``` +/// # +pub async fn convert_document_handler( + data: AFPluginData<ConvertDocumentPayloadPB>, + manager: AFPluginState<Weak<DocumentManager>>, +) -> DataResult<ConvertDocumentResponsePB, FlowyError> { + let manager = upgrade_document(manager)?; + let params: ConvertDocumentParams = data.into_inner().try_into()?; + + let document = manager.get_document(¶ms.document_id).await?; + let document_data = document.lock().get_document_data()?; + let parser = DocumentDataParser::new(Arc::new(document_data), params.range); + + if !params.parse_types.any_enabled() { + return data_result_ok(ConvertDocumentResponsePB::default()); + } + + let root = &parser.to_json(); + + data_result_ok(ConvertDocumentResponsePB { + json: params + .parse_types + .json + .then(|| serde_json::to_string(root).unwrap_or_default()), + html: params + .parse_types + .html + .then(|| parser.to_html_with_json(root)), + text: params + .parse_types + .text + .then(|| parser.to_text_with_json(root)), + }) +} + +/// Handler for converting a string to a JSON string. +/// # Examples +/// Basic usage: +/// ```txt +/// let test = DocumentEventTest::new().await; +/// let payload = ConvertDataToJsonPayloadPB { +/// data: "<p>Hello</p><p> World!</p>".to_string(), +/// input_type: InputTypePB::Html, +/// }; +/// let result: ConvertDataToJsonResponsePB = test.convert_data_to_json(payload).await; +/// let expect_json = json!({ "type": "page", "data": {}, "children": [{ "type": "paragraph", "children": [], "data": { "delta": [{ "insert": "Hello" }] } }, { "type": "paragraph", "children": [], "data": { "delta": [{ "insert": " World!" }] } }] }); +/// assert!(serde_json::from_str::<NestedBlock>(&result.json).unwrap().eq(&serde_json::from_value::<NestedBlock>(expect_json).unwrap())); +/// ``` +pub(crate) async fn convert_data_to_json_handler( + data: AFPluginData<ConvertDataToJsonPayloadPB>, +) -> DataResult<ConvertDataToJsonResponsePB, FlowyError> { + let payload: ConvertDataToJsonParams = data.validate()?.into_inner().try_into()?; + let parser = ExternalDataToNestedJSONParser::new(payload.data, payload.input_type); + + let result = match parser.to_nested_block() { + Some(result) => serde_json::to_string(&result)?, + None => "".to_string(), + }; + + data_result_ok(ConvertDataToJsonResponsePB { json: result }) +} diff --git a/frontend/rust-lib/flowy-document2/src/event_map.rs b/frontend/rust-lib/flowy-document2/src/event_map.rs index c9ff9569d6d2..a43967aa14f9 100644 --- a/frontend/rust-lib/flowy-document2/src/event_map.rs +++ b/frontend/rust-lib/flowy-document2/src/event_map.rs @@ -27,6 +27,11 @@ pub fn init(document_manager: Weak<DocumentManager>) -> AFPlugin { .event(DocumentEvent::GetDocumentSnapshots, get_snapshot_handler) .event(DocumentEvent::CreateText, create_text_handler) .event(DocumentEvent::ApplyTextDeltaEvent, apply_text_delta_handler) + .event(DocumentEvent::ConvertDocument, convert_document_handler) + .event( + DocumentEvent::ConvertDataToJSON, + convert_data_to_json_handler, + ) } #[derive(Debug, Clone, PartialEq, Eq, Hash, Display, ProtoBuf_Enum, Flowy_Event)] @@ -76,4 +81,18 @@ pub enum DocumentEvent { #[event(input = "TextDeltaPayloadPB")] ApplyTextDeltaEvent = 11, + + // document in event_handler.rs -> convert_document + #[event( + input = "ConvertDocumentPayloadPB", + output = "ConvertDocumentResponsePB" + )] + ConvertDocument = 12, + + // document in event_handler.rs -> convert_data_to_json + #[event( + input = "ConvertDataToJsonPayloadPB", + output = "ConvertDataToJsonResponsePB" + )] + ConvertDataToJSON = 13, } diff --git a/frontend/rust-lib/flowy-document2/src/manager.rs b/frontend/rust-lib/flowy-document2/src/manager.rs index ee8a9c9a9c49..feba726ffe4f 100644 --- a/frontend/rust-lib/flowy-document2/src/manager.rs +++ b/frontend/rust-lib/flowy-document2/src/manager.rs @@ -1,14 +1,16 @@ +use std::num::NonZeroUsize; +use std::sync::Arc; use std::sync::Weak; -use std::{collections::HashMap, sync::Arc}; -use collab::core::collab::MutexCollab; +use collab::core::collab::{CollabRawData, MutexCollab}; use collab_document::blocks::DocumentData; use collab_document::document::Document; -use collab_document::document_data::default_document_data; +use collab_document::document_data::{default_document_collab_data, default_document_data}; use collab_document::YrsDocAction; use collab_entity::CollabType; -use parking_lot::RwLock; -use tracing::instrument; +use lru::LruCache; +use parking_lot::Mutex; +use tracing::{event, instrument}; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::RocksCollabDB; @@ -22,9 +24,7 @@ use crate::reminder::DocumentReminderAction; pub trait DocumentUser: Send + Sync { fn user_id(&self) -> Result<i64, FlowyError>; - fn workspace_id(&self) -> Result<String, FlowyError>; - fn token(&self) -> Result<Option<String>, FlowyError>; // unused now. fn collab_db(&self, uid: i64) -> Result<Weak<RocksCollabDB>, FlowyError>; } @@ -32,7 +32,7 @@ pub trait DocumentUser: Send + Sync { pub struct DocumentManager { pub user: Arc<dyn DocumentUser>, collab_builder: Arc<AppFlowyCollabBuilder>, - documents: Arc<RwLock<HashMap<String, Arc<MutexDocument>>>>, + documents: Arc<Mutex<LruCache<String, Arc<MutexDocument>>>>, #[allow(dead_code)] cloud_service: Arc<dyn DocumentCloudService>, storage_service: Weak<dyn FileStorageService>, @@ -45,17 +45,18 @@ impl DocumentManager { cloud_service: Arc<dyn DocumentCloudService>, storage_service: Weak<dyn FileStorageService>, ) -> Self { + let documents = Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(10).unwrap()))); Self { user, collab_builder, - documents: Default::default(), + documents, cloud_service, storage_service, } } pub async fn initialize(&self, _uid: i64, _workspace_id: String) -> FlowyResult<()> { - self.documents.write().clear(); + self.documents.lock().clear(); Ok(()) } @@ -103,19 +104,39 @@ impl DocumentManager { /// Return the document #[tracing::instrument(level = "debug", skip(self), err)] pub async fn get_document(&self, doc_id: &str) -> FlowyResult<Arc<MutexDocument>> { - if let Some(doc) = self.documents.read().get(doc_id) { - return Ok(doc.clone()); + if let Some(doc) = self.documents.lock().get(doc_id).cloned() { + return Ok(doc); } let mut updates = vec![]; if !self.is_doc_exist(doc_id)? { // Try to get the document from the cloud service - updates = self + let result: Result<CollabRawData, FlowyError> = self .cloud_service - .get_document_updates(&self.user.workspace_id()?, doc_id) - .await?; + .get_document_updates(doc_id, &self.user.workspace_id()?) + .await; + + updates = match result { + Ok(data) => data, + Err(err) => { + if err.is_record_not_found() { + // The document's ID exists in the cloud, but its content does not. + // This occurs when user A's document hasn't finished syncing and user B tries to open it. + // As a result, a blank document is created for user B. + event!( + tracing::Level::INFO, + "can't find the document in the cloud, doc_id: {}", + doc_id + ); + vec![default_document_collab_data(doc_id).doc_state.to_vec()] + } else { + return Err(err); + } + }, + } } let uid = self.user.user_id()?; + event!(tracing::Level::DEBUG, "Initialize document: {}", doc_id); let collab = self.collab_for_document(uid, doc_id, updates).await?; let document = Arc::new(MutexDocument::open(doc_id, collab)?); @@ -123,8 +144,8 @@ impl DocumentManager { // and we don't want to subscribe to the document changes if we open the same document again. self .documents - .write() - .insert(doc_id.to_string(), document.clone()); + .lock() + .put(doc_id.to_string(), document.clone()); Ok(document) } @@ -143,8 +164,10 @@ impl DocumentManager { .map_err(internal_error) } + #[instrument(level = "debug", skip(self), err)] pub fn close_document(&self, doc_id: &str) -> FlowyResult<()> { - self.documents.write().remove(doc_id); + // TODO(nathan): remove the document from lru cache. Currently, we don't remove it from the cache. + // The lru will pop the least recently used document when the cache is full. Ok(()) } @@ -155,7 +178,9 @@ impl DocumentManager { txn.delete_doc(uid, &doc_id)?; Ok(()) }); - self.documents.write().remove(doc_id); + + // When deleting a document, we need to remove it from the cache. + self.documents.lock().pop(doc_id); } Ok(()) } @@ -195,19 +220,6 @@ impl DocumentManager { .build(uid, doc_id, CollabType::Document, updates, db) .await?; Ok(collab) - - // let doc_id = doc_id.to_string(); - // let (tx, rx) = oneshot::channel(); - // let collab_builder = self.collab_builder.clone(); - // tokio::spawn(async move { - // let collab = collab_builder - // .build(uid, &doc_id, CollabType::Document, updates, db) - // .await - // .unwrap(); - // let _ = tx.send(collab); - // }); - // - // Ok(rx.await.unwrap()) } fn is_doc_exist(&self, doc_id: &str) -> FlowyResult<bool> { diff --git a/frontend/rust-lib/flowy-document2/src/parser/constant.rs b/frontend/rust-lib/flowy-document2/src/parser/constant.rs new file mode 100644 index 000000000000..c13722fcd3f8 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/src/parser/constant.rs @@ -0,0 +1,123 @@ +pub const DELTA: &str = "delta"; +pub const LEVEL: &str = "level"; +pub const NUMBER: &str = "number"; +pub const CHECKED: &str = "checked"; + +pub const COLLAPSED: &str = "collapsed"; +pub const LANGUAGE: &str = "language"; + +pub const ICON: &str = "icon"; +pub const WIDTH: &str = "width"; +pub const HEIGHT: &str = "height"; +pub const URL: &str = "url"; +pub const CAPTION: &str = "caption"; +pub const ALIGN: &str = "align"; + +pub const PAGE: &str = "page"; +pub const HEADING: &str = "heading"; +pub const PARAGRAPH: &str = "paragraph"; +pub const NUMBERED_LIST: &str = "numbered_list"; +pub const BULLETED_LIST: &str = "bulleted_list"; +pub const TODO_LIST: &str = "todo_list"; +pub const TOGGLE_LIST: &str = "toggle_list"; +pub const QUOTE: &str = "quote"; +pub const CALLOUT: &str = "callout"; +pub const IMAGE: &str = "image"; +pub const DIVIDER: &str = "divider"; +pub const MATH_EQUATION: &str = "math_equation"; +pub const BOLD: &str = "bold"; +pub const ITALIC: &str = "italic"; +pub const STRIKETHROUGH: &str = "strikethrough"; +pub const CODE: &str = "code"; +pub const UNDERLINE: &str = "underline"; +pub const FONT_COLOR: &str = "font_color"; +pub const BG_COLOR: &str = "bg_color"; + +pub const FORMULA: &str = "formula"; +pub const MENTION: &str = "mention"; + +pub const TEXT_DIRECTION: &str = "text_direction"; + +pub const HTML_TAG_NAME: &str = "html"; +pub const HR_TAG_NAME: &str = "hr"; +pub const META_TAG_NAME: &str = "meta"; +pub const LINK_TAG_NAME: &str = "link"; +pub const SCRIPT_TAG_NAME: &str = "script"; +pub const STYLE_TAG_NAME: &str = "style"; +pub const IFRAME_TAG_NAME: &str = "iframe"; +pub const NOSCRIPT_TAG_NAME: &str = "noscript"; +pub const HEAD_TAG_NAME: &str = "head"; +pub const H1_TAG_NAME: &str = "h1"; +pub const H2_TAG_NAME: &str = "h2"; +pub const H3_TAG_NAME: &str = "h3"; +pub const H4_TAG_NAME: &str = "h4"; +pub const H5_TAG_NAME: &str = "h5"; +pub const H6_TAG_NAME: &str = "h6"; +pub const P_TAG_NAME: &str = "p"; +pub const ASIDE_TAG_NAME: &str = "aside"; +pub const ARTICLE_TAG_NAME: &str = "article"; +pub const UL_TAG_NAME: &str = "ul"; +pub const OL_TAG_NAME: &str = "ol"; +pub const LI_TAG_NAME: &str = "li"; +pub const BLOCKQUOTE_TAG_NAME: &str = "blockquote"; +pub const PRE_TAG_NAME: &str = "pre"; +pub const IMG_TAG_NAME: &str = "img"; +pub const B_TAG_NAME: &str = "b"; +pub const CODE_TAG_NAME: &str = "code"; +pub const STRONG_TAG_NAME: &str = "strong"; +pub const EM_TAG_NAME: &str = "em"; +pub const U_TAG_NAME: &str = "u"; +pub const S_TAG_NAME: &str = "s"; +pub const SPAN_TAG_NAME: &str = "span"; +pub const BR_TAG_NAME: &str = "br"; + +pub const A_TAG_NAME: &str = "a"; +pub const BASE_TAG_NAME: &str = "base"; +pub const ABBR_TAG_NAME: &str = "abbr"; +pub const ADDRESS_TAG_NAME: &str = "address"; +pub const DBO_TAG_NAME: &str = "bdo"; +pub const DIR_ATTR_NAME: &str = "dir"; + +pub const RTL_ATTR_VALUE: &str = "rtl"; + +pub const CITE_TAG_NAME: &str = "cite"; + +pub const DEL_TAG_NAME: &str = "del"; + +pub const DETAILS_TAG_NAME: &str = "details"; + +pub const SUMMARY_TAG_NAME: &str = "summary"; + +pub const DFN_TAG_NAME: &str = "dfn"; + +pub const DL_TAG_NAME: &str = "dl"; + +pub const I_TAG_NAME: &str = "i"; +pub const VAR_TAG_NAME: &str = "var"; + +pub const INS_TAG_NAME: &str = "ins"; +pub const MENU_TAG_NAME: &str = "menu"; + +pub const MARK_TAG_NAME: &str = "mark"; + +pub const FONT_WEIGHT: &str = "font-weight"; +pub const FONT_STYLE: &str = "font-style"; +pub const TEXT_DECORATION: &str = "text-decoration"; + +pub const BACKGROUND_COLOR: &str = "background-color"; +pub const COLOR: &str = "color"; +pub const LINE_THROUGH: &str = "line-through"; + +pub const FONT_STYLE_ITALIC: &str = "font-style: italic;"; +pub const TEXT_DECORATION_UNDERLINE: &str = "text-decoration: underline;"; +pub const TEXT_DECORATION_LINE_THROUGH: &str = "text-decoration: line-through;"; +pub const FONT_WEIGHT_BOLD: &str = "font-weight: bold;"; +pub const FONT_FAMILY_FANTASY: &str = "font-family: fantasy;"; + +pub const SRC: &str = "src"; +pub const HREF: &str = "href"; +pub const ROLE: &str = "role"; +pub const CHECKBOX: &str = "checkbox"; +pub const ARIA_CHECKED: &str = "aria-checked"; +pub const CLASS: &str = "class"; +pub const STYLE: &str = "style"; diff --git a/frontend/rust-lib/flowy-document2/src/parser/document_data_parser.rs b/frontend/rust-lib/flowy-document2/src/parser/document_data_parser.rs new file mode 100644 index 000000000000..d92857f7b721 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/src/parser/document_data_parser.rs @@ -0,0 +1,151 @@ +use crate::parser::constant::DELTA; +use crate::parser::parser_entities::{ConvertBlockToHtmlParams, InsertDelta, NestedBlock, Range}; +use crate::parser::utils::{get_delta_for_block, get_delta_for_selection}; +use collab_document::blocks::DocumentData; +use std::sync::Arc; + +/// DocumentDataParser is a struct for parsing a document's data and converting it to JSON, HTML, or text. +pub struct DocumentDataParser { + /// The document data to parse. + pub document_data: Arc<DocumentData>, + /// The range of the document data to parse. If the range is None, the entire document data will be parsed. + pub range: Option<Range>, +} + +impl DocumentDataParser { + pub fn new(document_data: Arc<DocumentData>, range: Option<Range>) -> Self { + Self { + document_data, + range, + } + } + + /// Converts the JSON to an HTML representation. + pub fn to_html_with_json(&self, json: &Option<NestedBlock>) -> String { + let mut html = String::new(); + html.push_str("<meta charset=\"UTF-8\">"); + if let Some(json) = json { + let params = ConvertBlockToHtmlParams { + prev_block_ty: None, + next_block_ty: None, + }; + html.push_str(json.convert_to_html(params).as_str()); + } + html + } + + /// Converts the JSON to plain text. + pub fn to_text_with_json(&self, json: &Option<NestedBlock>) -> String { + if let Some(json) = json { + json.convert_to_text() + } else { + String::new() + } + } + + /// Converts the document data to HTML. + pub fn to_html(&self) -> String { + let json = self.to_json(); + self.to_html_with_json(&json) + } + + /// Converts the document data to plain text. + pub fn to_text(&self) -> String { + let json = self.to_json(); + self.to_text_with_json(&json) + } + + /// Converts the document data to a nested JSON structure, considering the optional range. + pub fn to_json(&self) -> Option<NestedBlock> { + let root_id = &self.document_data.page_id; + let mut children = vec![]; + let mut start_found = false; + let mut end_found = false; + self.block_to_nested_block(root_id, &mut children, &mut start_found, &mut end_found) + } + + fn block_to_nested_block( + &self, + block_id: &str, + children: &mut Vec<NestedBlock>, + start_found: &mut bool, + end_found: &mut bool, + ) -> Option<NestedBlock> { + let block = self.document_data.blocks.get(block_id)?; + let delta = self.get_delta(block_id); + + // Prepare the data, including delta if available + let mut data = block.data.clone(); + if let Some(delta) = delta { + if let Ok(delta_value) = serde_json::to_value(delta) { + data.insert(DELTA.to_string(), delta_value); + } + } + + // Get the child IDs for the current block + if let Some(block_children_ids) = self.document_data.meta.children_map.get(&block.children) { + for child_id in block_children_ids { + if let Some(range) = &self.range { + if child_id == &range.start.block_id { + *start_found = true; + } + + if child_id == &range.end.block_id { + *end_found = true; + // Process the "end" block recursively + self.process_child_block(child_id, children, start_found, end_found); + break; + } + } + + if self.range.is_some() { + if !*start_found { + // Don't insert children before the "start" block is found + self.block_to_nested_block(child_id, children, start_found, end_found); + continue; + } + if *end_found { + // Stop inserting children after the "end" block is found + break; + } + } + + // Process child blocks recursively + self.process_child_block(child_id, children, start_found, end_found); + } + } + + Some(NestedBlock { + ty: block.ty.clone(), + children: children.to_owned(), + data, + }) + } + + fn get_delta(&self, block_id: &str) -> Option<Vec<InsertDelta>> { + match &self.range { + Some(range) if block_id == range.start.block_id => { + get_delta_for_selection(&range.start, &self.document_data) + }, + Some(range) if block_id == range.end.block_id => { + get_delta_for_selection(&range.end, &self.document_data) + }, + _ => get_delta_for_block(block_id, &self.document_data), + } + } + + fn process_child_block( + &self, + child_id: &str, + children: &mut Vec<NestedBlock>, + start_found: &mut bool, + end_found: &mut bool, + ) { + let mut child_children = vec![]; + if let Some(child) = + self.block_to_nested_block(child_id, &mut child_children, start_found, end_found) + { + children.push(child); + } + } +} diff --git a/frontend/rust-lib/flowy-document2/src/parser/external/mod.rs b/frontend/rust-lib/flowy-document2/src/parser/external/mod.rs new file mode 100644 index 000000000000..8a43408ba153 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/src/parser/external/mod.rs @@ -0,0 +1,2 @@ +pub mod parser; +mod utils; diff --git a/frontend/rust-lib/flowy-document2/src/parser/external/parser.rs b/frontend/rust-lib/flowy-document2/src/parser/external/parser.rs new file mode 100644 index 000000000000..4bc3618744f5 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/src/parser/external/parser.rs @@ -0,0 +1,40 @@ +use crate::parser::external::utils::{flatten_element_to_block, parse_plaintext_to_nested_block}; +use crate::parser::parser_entities::{InputType, NestedBlock}; +use scraper::Html; + +/// External data to nested json parser. +#[derive(Debug, Clone, Default)] +pub struct ExternalDataToNestedJSONParser { + /// External data. for example: html string, plain text string. + external_data: String, + /// External data type. for example: [InputType]::Html, [InputType]::PlainText. + input_type: InputType, +} + +impl ExternalDataToNestedJSONParser { + pub fn new(data: String, input_type: InputType) -> Self { + Self { + external_data: data, + input_type, + } + } + + /// Format to nested block. + /// + /// Example: + /// - input html: <p><strong>Hello</strong></p><p> World!</p> + /// - output json: + /// ```json + /// { "type": "page", "data": {}, "children": [{ "type": "paragraph", "children": [], "data": { "delta": [{ "insert": "Hello", attributes: { "bold": true } }] } }, { "type": "paragraph", "children": [], "data": { "delta": [{ "insert": " World!", attributes: null }] } }] } + /// ``` + pub fn to_nested_block(&self) -> Option<NestedBlock> { + match self.input_type { + InputType::Html => { + let fragment = Html::parse_fragment(&self.external_data); + let root_element = fragment.root_element(); + flatten_element_to_block(root_element) + }, + InputType::PlainText => parse_plaintext_to_nested_block(&self.external_data), + } + } +} diff --git a/frontend/rust-lib/flowy-document2/src/parser/external/utils.rs b/frontend/rust-lib/flowy-document2/src/parser/external/utils.rs new file mode 100644 index 000000000000..d170706cd3f4 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/src/parser/external/utils.rs @@ -0,0 +1,559 @@ +use crate::parser::constant::*; +use crate::parser::parser_entities::{InsertDelta, NestedBlock}; +use scraper::node::Attrs; +use scraper::ElementRef; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +const INLINE_TAGS: [&str; 18] = [ + A_TAG_NAME, + EM_TAG_NAME, + STRONG_TAG_NAME, + U_TAG_NAME, + S_TAG_NAME, + CODE_TAG_NAME, + SPAN_TAG_NAME, + ADDRESS_TAG_NAME, + BASE_TAG_NAME, + CITE_TAG_NAME, + DFN_TAG_NAME, + I_TAG_NAME, + VAR_TAG_NAME, + ABBR_TAG_NAME, + INS_TAG_NAME, + DEL_TAG_NAME, + MARK_TAG_NAME, + "", +]; + +const LINK_TAGS: [&str; 2] = [A_TAG_NAME, BASE_TAG_NAME]; +const ITALIC_TAGS: [&str; 6] = [ + EM_TAG_NAME, + I_TAG_NAME, + VAR_TAG_NAME, + CITE_TAG_NAME, + DFN_TAG_NAME, + ADDRESS_TAG_NAME, +]; + +const BOLD_TAGS: [&str; 2] = [STRONG_TAG_NAME, B_TAG_NAME]; + +const UNDERLINE_TAGS: [&str; 3] = [U_TAG_NAME, ABBR_TAG_NAME, INS_TAG_NAME]; +const STRIKETHROUGH_TAGS: [&str; 2] = [S_TAG_NAME, DEL_TAG_NAME]; +const IGNORE_TAGS: [&str; 7] = [ + META_TAG_NAME, + HEAD_TAG_NAME, + LINK_TAG_NAME, + SCRIPT_TAG_NAME, + STYLE_TAG_NAME, + NOSCRIPT_TAG_NAME, + IFRAME_TAG_NAME, +]; + +const HEADING_TAGS: [&str; 6] = [ + H1_TAG_NAME, + H2_TAG_NAME, + H3_TAG_NAME, + H4_TAG_NAME, + H5_TAG_NAME, + H6_TAG_NAME, +]; + +const SHOULD_EXPAND_TAGS: [&str; 4] = [UL_TAG_NAME, OL_TAG_NAME, DL_TAG_NAME, MENU_TAG_NAME]; + +#[derive(Debug, Serialize, Deserialize)] +pub enum JSONResult { + Block(NestedBlock), + Delta(InsertDelta), + BlockArray(Vec<NestedBlock>), + DeltaArray(Vec<InsertDelta>), +} + +/// Flatten element to block +pub fn flatten_element_to_block(node: ElementRef) -> Option<NestedBlock> { + if let Some(JSONResult::Block(block)) = flatten_element_to_json(node, &None, &None) { + return Some(block); + } + + None +} + +/// Parse plaintext to nested block +pub fn parse_plaintext_to_nested_block(plaintext: &str) -> Option<NestedBlock> { + let lines: Vec<&str> = plaintext + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); + let mut current_block = NestedBlock { + ty: PAGE.to_string(), + ..Default::default() + }; + + for line in lines { + let mut data = HashMap::new(); + + // Insert plaintext into delta + if let Ok(delta) = serde_json::to_value(vec![InsertDelta { + insert: line.to_string(), + attributes: None, + }]) { + data.insert(DELTA.to_string(), delta); + } + + // Create a new block for each non-empty line + current_block.children.push(NestedBlock { + ty: PARAGRAPH.to_string(), + data, + children: Default::default(), + }); + } + + if current_block.children.is_empty() { + return None; + } + Some(current_block) +} + +fn flatten_element_to_json( + node: ElementRef, + list_type: &Option<String>, + attributes: &Option<HashMap<String, Value>>, +) -> Option<JSONResult> { + let tag_name = get_tag_name(node.to_owned()); + + if IGNORE_TAGS.contains(&tag_name.as_str()) { + return None; + } + + if INLINE_TAGS.contains(&tag_name.as_str()) { + return process_inline_element(node, attributes.to_owned()); + } + + let mut data = HashMap::new(); + // insert dir into attrs when dir is rtl + // for example: <bdo dir="rtl">Right to left</bdo> -> { "attributes": { "text_direction": "rtl" }, "insert": "Right to left" } + if let Some(dir) = find_attribute_value(node.to_owned(), DIR_ATTR_NAME) { + data.insert(TEXT_DIRECTION.to_string(), Value::String(dir)); + } + + if HEADING_TAGS.contains(&tag_name.as_str()) { + return process_heading_element(node, data); + } + + if SHOULD_EXPAND_TAGS.contains(&tag_name.as_str()) { + return process_nested_element(node); + } + + match tag_name.as_str() { + LI_TAG_NAME => process_li_element(node, list_type.to_owned(), data), + BLOCKQUOTE_TAG_NAME | DETAILS_TAG_NAME => { + process_node_summary_and_details(QUOTE.to_string(), node, data) + }, + PRE_TAG_NAME => process_code_element(node), + IMG_TAG_NAME => process_image_element(node), + B_TAG_NAME => { + // Compatible with Google Docs, <b id=xxx> is the document top level tag, so we need to process it's children + let id = find_attribute_value(node.to_owned(), "id"); + if id.is_some() { + return process_nested_element(node); + } + process_inline_element(node, attributes.to_owned()) + }, + + _ => process_default_element(node, data), + } +} + +fn process_default_element( + node: ElementRef, + mut data: HashMap<String, Value>, +) -> Option<JSONResult> { + let tag_name = get_tag_name(node.to_owned()); + + let ty = match tag_name.as_str() { + HTML_TAG_NAME => PAGE, + P_TAG_NAME => PARAGRAPH, + ASIDE_TAG_NAME | ARTICLE_TAG_NAME => CALLOUT, + HR_TAG_NAME => DIVIDER, + _ => PARAGRAPH, + }; + + let (delta, children) = process_node_children(node, &None, None); + + if !delta.is_empty() { + data.insert(DELTA.to_string(), delta_to_json(&delta)); + } + Some(JSONResult::Block(NestedBlock { + ty: ty.to_string(), + children, + data, + })) +} + +fn process_image_element(node: ElementRef) -> Option<JSONResult> { + let mut data = HashMap::new(); + if let Some(src) = find_attribute_value(node, SRC) { + data.insert(URL.to_string(), Value::String(src)); + } + Some(JSONResult::Block(NestedBlock { + ty: IMAGE.to_string(), + children: Default::default(), + data, + })) +} + +fn process_code_element(node: ElementRef) -> Option<JSONResult> { + let mut data = HashMap::new(); + + // find code element and get language and delta, then insert into data + if let Some(code_child) = find_child_node(node.to_owned(), CODE_TAG_NAME.to_string()) { + // get language + if let Some(class) = find_attribute_value(code_child.to_owned(), CLASS) { + let lang = class.split('-').last().unwrap_or_default(); + data.insert(LANGUAGE.to_string(), Value::String(lang.to_string())); + } + // get delta + let text = code_child.text().collect::<String>(); + if let Ok(delta) = serde_json::to_value(vec![InsertDelta { + insert: text, + attributes: None, + }]) { + data.insert(DELTA.to_string(), delta); + } + } + + Some(JSONResult::Block(NestedBlock { + ty: CODE.to_string(), + children: Default::default(), + data, + })) +} + +// process "ul" | "ol" | "dl" | "menu" element +fn process_nested_element(node: ElementRef) -> Option<JSONResult> { + let tag_name = get_tag_name(node.to_owned()); + + let ty = match tag_name.as_str() { + UL_TAG_NAME => BULLETED_LIST, + OL_TAG_NAME => NUMBERED_LIST, + _ => PARAGRAPH, + }; + let (_, children) = process_node_children(node, &Some(ty.to_string()), None); + Some(JSONResult::BlockArray(children)) +} + +// process <li> element, if it's a checkbox, then return a todo list, otherwise return a normal list. +fn process_li_element( + node: ElementRef, + list_type: Option<String>, + mut data: HashMap<String, Value>, +) -> Option<JSONResult> { + let mut ty = list_type.unwrap_or(BULLETED_LIST.to_string()); + if let Some(role) = find_attribute_value(node.to_owned(), ROLE) { + if role == CHECKBOX { + if let Some(checked_attr) = find_attribute_value(node.to_owned(), ARIA_CHECKED) { + let checked = match checked_attr.as_str() { + "true" => true, + "false" => false, + _ => false, + }; + data.insert( + CHECKED.to_string(), + serde_json::to_value(checked).unwrap_or_default(), + ); + } + data.insert( + CHECKED.to_string(), + serde_json::to_value(false).unwrap_or_default(), + ); + ty = TODO_LIST.to_string(); + } + } + process_node_summary_and_details(ty, node, data) +} + +// Process children and handle potential nesting +// <li> +// <p> title </p> +// <p> content </p> +// </li> +// Or Process children and handle potential consecutive arrangement +// <li>title<p>content</p></li> +// li | blockquote | details +fn process_node_summary_and_details( + ty: String, + node: ElementRef, + mut data: HashMap<String, Value>, +) -> Option<JSONResult> { + let (delta, children) = process_node_children(node, &Some(ty.to_string()), None); + if delta.is_empty() { + if let Some(first_child) = children.first() { + let mut data = HashMap::new(); + if let Some(first_child_delta) = first_child.data.get(DELTA) { + data.insert(DELTA.to_string(), first_child_delta.to_owned()); + let rest_children = children.iter().skip(1).cloned().collect(); + return Some(JSONResult::Block(NestedBlock { + ty, + children: rest_children, + data, + })); + } + } + } else { + data.insert(DELTA.to_string(), delta_to_json(&delta)); + } + Some(JSONResult::Block(NestedBlock { + ty, + children, + data: data.to_owned(), + })) +} + +fn process_heading_element( + node: ElementRef, + mut data: HashMap<String, Value>, +) -> Option<JSONResult> { + let tag_name = get_tag_name(node.to_owned()); + let level = match tag_name.chars().last().unwrap_or_default() { + '1' => 1, + '2' => 2, + // default to h3 even if it's h4, h5, h6 + _ => 3, + }; + + data.insert( + LEVEL.to_string(), + serde_json::to_value(level).unwrap_or_default(), + ); + + let (delta, children) = process_node_children(node, &None, None); + if !delta.is_empty() { + data.insert( + DELTA.to_string(), + serde_json::to_value(delta).unwrap_or_default(), + ); + } + + Some(JSONResult::Block(NestedBlock { + ty: HEADING.to_string(), + children, + data, + })) +} + +// process <a> <em> <strong> <u> <s> <code> <span> <br> +fn process_inline_element( + node: ElementRef, + attributes: Option<HashMap<String, Value>>, +) -> Option<JSONResult> { + let tag_name = get_tag_name(node.to_owned()); + + let attributes = get_delta_attributes_for(&tag_name, &get_node_attrs(node), attributes); + let (delta, children) = process_node_children(node, &None, attributes); + Some(if !delta.is_empty() { + JSONResult::DeltaArray(delta) + } else { + JSONResult::BlockArray(children) + }) +} + +fn process_node_children( + node: ElementRef, + list_type: &Option<String>, + attributes: Option<HashMap<String, Value>>, +) -> (Vec<InsertDelta>, Vec<NestedBlock>) { + let tag_name = get_tag_name(node.to_owned()); + let mut delta = Vec::new(); + let mut children = Vec::new(); + + for child in node.children() { + if let Some(child_element) = ElementRef::wrap(child) { + if let Some(child_json) = flatten_element_to_json(child_element, list_type, &attributes) { + match child_json { + JSONResult::Delta(op) => delta.push(op), + JSONResult::Block(block) => children.push(block), + JSONResult::BlockArray(blocks) => children.extend(blocks), + JSONResult::DeltaArray(ops) => delta.extend(ops), + } + } + } else { + // put text into delta while child is a text node + let text = child + .value() + .as_text() + .map(|text| text.text.to_string()) + .unwrap_or_default(); + + if let Some(op) = node_to_delta(&tag_name, text, &mut get_node_attrs(node), &attributes) { + delta.push(op); + } + } + } + + (delta, children) +} + +// get attributes from style +// for example: style="font-weight: bold; font-style: italic; text-decoration: underline; text-decoration: line-through;" +fn get_attributes_with_style(style: &str) -> HashMap<String, Value> { + let mut attributes = HashMap::new(); + + for property in style.split(';') { + let parts: Vec<&str> = property.split(':').map(|s| s.trim()).collect::<Vec<&str>>(); + + if parts.len() != 2 { + continue; + } + + let (key, value) = (parts[0], parts[1]); + + match key { + FONT_WEIGHT if value.contains(BOLD) => { + attributes.insert(BOLD.to_string(), Value::Bool(true)); + }, + FONT_STYLE if value.contains(ITALIC) => { + attributes.insert(ITALIC.to_string(), Value::Bool(true)); + }, + TEXT_DECORATION if value.contains(UNDERLINE) => { + attributes.insert(UNDERLINE.to_string(), Value::Bool(true)); + }, + TEXT_DECORATION if value.contains(LINE_THROUGH) => { + attributes.insert(STRIKETHROUGH.to_string(), Value::Bool(true)); + }, + BACKGROUND_COLOR => { + attributes.insert(BG_COLOR.to_string(), Value::String(value.to_string())); + }, + COLOR => { + attributes.insert(FONT_COLOR.to_string(), Value::String(value.to_string())); + }, + _ => {}, + } + } + + attributes +} + +// get attributes from tag name +// input <a href="https://www.google.com">Google</a> +// export attributes: { "href": "https://www.google.com" } +// input <em>Italic</em> +// export attributes: { "italic": true } +// input <strong>Bold</strong> +// export attributes: { "bold": true } +// input <u>Underline</u> +// export attributes: { "underline": true } +// input <s>Strikethrough</s> +// export attributes: { "strikethrough": true } +// input <code>Code</code> +// export attributes: { "code": true } +fn get_delta_attributes_for( + tag_name: &str, + attrs: &Attrs, + parent_attributes: Option<HashMap<String, Value>>, +) -> Option<HashMap<String, Value>> { + let href = find_attribute_value_from_attrs(attrs, HREF); + + let style = find_attribute_value_from_attrs(attrs, STYLE); + + let mut attributes = get_attributes_with_style(&style); + if let Some(parent_attributes) = parent_attributes { + parent_attributes.iter().for_each(|(k, v)| { + attributes.insert(k.to_string(), v.clone()); + }); + } + + match tag_name { + CODE_TAG_NAME => { + attributes.insert(CODE.to_string(), Value::Bool(true)); + }, + MARK_TAG_NAME => { + attributes.insert(BG_COLOR.to_string(), Value::String("#FFFF00".to_string())); + }, + _ => { + if LINK_TAGS.contains(&tag_name) { + attributes.insert(HREF.to_string(), Value::String(href)); + } + if ITALIC_TAGS.contains(&tag_name) { + attributes.insert(ITALIC.to_string(), Value::Bool(true)); + } + if BOLD_TAGS.contains(&tag_name) { + attributes.insert(BOLD.to_string(), Value::Bool(true)); + } + if UNDERLINE_TAGS.contains(&tag_name) { + attributes.insert(UNDERLINE.to_string(), Value::Bool(true)); + } + if STRIKETHROUGH_TAGS.contains(&tag_name) { + attributes.insert(STRIKETHROUGH.to_string(), Value::Bool(true)); + } + }, + } + if attributes.is_empty() { + None + } else { + Some(attributes) + } +} + +// transform text_node to delta +// input <a href="https://www.google.com">Google</a> +// export delta: [{ "insert": "Google", "attributes": { "href": "https://www.google.com" } }] +fn node_to_delta( + tag_name: &str, + text: String, + attrs: &mut Attrs, + parent_attributes: &Option<HashMap<String, Value>>, +) -> Option<InsertDelta> { + let attributes = get_delta_attributes_for(tag_name, attrs, parent_attributes.to_owned()); + if text.trim().is_empty() { + return None; + } + + Some(InsertDelta { + insert: text, + attributes, + }) +} + +// get tag name from node +fn get_tag_name(node: ElementRef) -> String { + node.value().name().to_string() +} + +fn get_node_attrs(node: ElementRef) -> Attrs { + node.value().attrs() +} +// find attribute value from node +fn find_attribute_value(node: ElementRef, attr_name: &str) -> Option<String> { + node + .value() + .attrs() + .find(|(name, _)| *name == attr_name) + .map(|(_, value)| value.to_string()) +} + +fn find_attribute_value_from_attrs(attrs: &Attrs, attr_name: &str) -> String { + // The attrs need to be mutable, because the find method will consume the attrs + // So we clone it and use the clone one + let mut attrs = attrs.clone(); + attrs + .find(|(name, _)| *name == attr_name) + .map(|(_, value)| value.to_string()) + .unwrap_or_default() +} + +fn find_child_node(node: ElementRef, child_tag_name: String) -> Option<ElementRef> { + node + .children() + .find(|child| { + if let Some(child_element) = ElementRef::wrap(child.to_owned()) { + return get_tag_name(child_element) == child_tag_name; + } + false + }) + .and_then(|child| ElementRef::wrap(child.to_owned())) +} + +fn delta_to_json(delta: &Vec<InsertDelta>) -> Value { + serde_json::to_value(delta).unwrap_or_default() +} diff --git a/frontend/rust-lib/flowy-document2/src/parser/mod.rs b/frontend/rust-lib/flowy-document2/src/parser/mod.rs index 22fdbb38c88f..305d7ee0e80d 100644 --- a/frontend/rust-lib/flowy-document2/src/parser/mod.rs +++ b/frontend/rust-lib/flowy-document2/src/parser/mod.rs @@ -1 +1,6 @@ +pub mod constant; +pub mod document_data_parser; +pub mod external; pub mod json; +pub mod parser_entities; +pub mod utils; diff --git a/frontend/rust-lib/flowy-document2/src/parser/parser_entities.rs b/frontend/rust-lib/flowy-document2/src/parser/parser_entities.rs new file mode 100644 index 000000000000..cb7bf35e27e8 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/src/parser/parser_entities.rs @@ -0,0 +1,537 @@ +use crate::parse::NotEmptyStr; +use crate::parser::constant::*; +use crate::parser::utils::{ + convert_insert_delta_from_json, convert_nested_block_children_to_html, delta_to_html, + delta_to_text, required_not_empty_str, serialize_color_attribute, +}; +use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; +use flowy_error::ErrorCode; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use validator::Validate; + +#[derive(Default, ProtoBuf)] +pub struct SelectionPB { + #[pb(index = 1)] + pub block_id: String, + + #[pb(index = 2)] + pub index: u32, + + #[pb(index = 3)] + pub length: u32, +} + +#[derive(Default, ProtoBuf)] +pub struct RangePB { + #[pb(index = 1)] + pub start: SelectionPB, + + #[pb(index = 2)] + pub end: SelectionPB, +} + +/** +* ExportTypePB + * @field json: bool // export json data + * @field html: bool // export html data + * @field text: bool // export text data + */ +#[derive(Default, ProtoBuf, Debug, Clone)] +pub struct ParseTypePB { + #[pb(index = 1)] + pub json: bool, + + #[pb(index = 2)] + pub html: bool, + + #[pb(index = 3)] + pub text: bool, +} +/** +* ConvertDocumentPayloadPB + * @field document_id: String + * @file range: Option<RangePB> - optional // if range is None, copy the whole document + * @field parse_types: [ParseTypePB] + */ +#[derive(Default, ProtoBuf)] +pub struct ConvertDocumentPayloadPB { + #[pb(index = 1)] + pub document_id: String, + + #[pb(index = 2, one_of)] + pub range: Option<RangePB>, + + #[pb(index = 3)] + pub parse_types: ParseTypePB, +} + +#[derive(Default, ProtoBuf, Debug)] +pub struct ConvertDocumentResponsePB { + #[pb(index = 1, one_of)] + pub json: Option<String>, + #[pb(index = 2, one_of)] + pub html: Option<String>, + #[pb(index = 3, one_of)] + pub text: Option<String>, +} + +pub struct Selection { + pub block_id: String, + pub index: u32, + pub length: u32, +} + +pub struct Range { + pub start: Selection, + pub end: Selection, +} + +pub struct ParseType { + pub json: bool, + pub html: bool, + pub text: bool, +} + +pub struct ConvertDocumentParams { + pub document_id: String, + pub range: Option<Range>, + pub parse_types: ParseType, +} + +impl ParseType { + pub fn any_enabled(&self) -> bool { + self.json || self.html || self.text + } +} + +impl From<SelectionPB> for Selection { + fn from(data: SelectionPB) -> Self { + Selection { + block_id: data.block_id, + index: data.index, + length: data.length, + } + } +} + +impl From<RangePB> for Range { + fn from(data: RangePB) -> Self { + Range { + start: data.start.into(), + end: data.end.into(), + } + } +} + +impl From<ParseTypePB> for ParseType { + fn from(data: ParseTypePB) -> Self { + ParseType { + json: data.json, + html: data.html, + text: data.text, + } + } +} +impl TryInto<ConvertDocumentParams> for ConvertDocumentPayloadPB { + type Error = ErrorCode; + fn try_into(self) -> Result<ConvertDocumentParams, Self::Error> { + let document_id = + NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; + let range = self.range.map(|data| data.into()); + + Ok(ConvertDocumentParams { + document_id: document_id.0, + range, + parse_types: self.parse_types.into(), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InsertDelta { + #[serde(default)] + pub insert: String, + #[serde(default)] + pub attributes: Option<HashMap<String, Value>>, +} + +impl InsertDelta { + pub fn to_text(&self) -> String { + self.insert.clone() + } + + pub fn to_html(&self) -> String { + let mut html = String::new(); + let mut style = String::new(); + let mut html_attributes = String::new(); + // If there are attributes, serialize them as a HashMap. + if let Some(attrs) = &self.attributes { + // Serialize the color attributes. + style.push_str(&serialize_color_attribute(attrs, FONT_COLOR, COLOR)); + // Serialize the background color attributes. + style.push_str(&serialize_color_attribute( + attrs, + BG_COLOR, + BACKGROUND_COLOR, + )); + // Serialize the href attributes. + if let Some(href) = attrs.get(HREF) { + html.push_str(&format!("<{} {}={}>", A_TAG_NAME, HREF, href)); + } + // Serialize the code attributes. + if let Some(code) = attrs.get(CODE) { + if code.as_bool().unwrap_or(false) { + html.push_str(&format!("<{}>", CODE_TAG_NAME)); + } + } + + // Serialize the italic, underline, strikethrough, bold, formula attributes. + if let Some(italic) = attrs.get(ITALIC) { + if italic.as_bool().unwrap_or(false) { + style.push_str(FONT_STYLE_ITALIC); + } + } + if let Some(underline) = attrs.get(UNDERLINE) { + if underline.as_bool().unwrap_or(false) { + style.push_str(TEXT_DECORATION_UNDERLINE); + } + } + if let Some(strikethrough) = attrs.get(STRIKETHROUGH) { + if strikethrough.as_bool().unwrap_or(false) { + style.push_str(TEXT_DECORATION_LINE_THROUGH); + } + } + if let Some(bold) = attrs.get(BOLD) { + if bold.as_bool().unwrap_or(false) { + style.push_str(FONT_WEIGHT_BOLD); + } + } + if let Some(formula) = attrs.get(FORMULA) { + if formula.as_bool().unwrap_or(false) { + style.push_str(FONT_FAMILY_FANTASY); + } + } + if let Some(direction) = attrs.get(TEXT_DIRECTION) { + html_attributes.push_str(&format!(" {}=\"{}\"", DIR_ATTR_NAME, direction)); + } + } + if !style.is_empty() { + html_attributes.push_str(&format!(" {}=\"{}\"", STYLE, style)); + } + + if !html_attributes.is_empty() { + html.push_str(&format!("<{}{}>", SPAN_TAG_NAME, html_attributes)); + } + // Serialize the insert field. + html.push_str(&self.insert); + + // Close the style tag. + if !html_attributes.is_empty() { + html.push_str(&format!("</{}>", SPAN_TAG_NAME)); + } + // Close the tags: <a>, <code>. + if let Some(attrs) = &self.attributes { + if attrs.contains_key(CODE) { + html.push_str(&format!("</{}>", CODE_TAG_NAME)); + } + if attrs.contains_key(HREF) { + html.push_str(&format!("</{}>", A_TAG_NAME)); + } + } + html + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NestedBlock { + #[serde(default)] + #[serde(rename = "type")] + pub ty: String, + #[serde(default)] + pub data: HashMap<String, Value>, + #[serde(default)] + pub children: Vec<NestedBlock>, +} + +impl Eq for NestedBlock {} + +impl PartialEq for NestedBlock { + fn eq(&self, other: &Self) -> bool { + self.ty == other.ty + && self.data.iter().all(|(k, v)| { + let other_v = other.data.get(k).unwrap_or(&Value::Null); + if k == DELTA { + let v = convert_insert_delta_from_json(v); + let other_v = convert_insert_delta_from_json(other_v); + return v == other_v; + } + v == other_v + }) + && self.children == other.children + } +} + +impl NestedBlock { + pub fn new(ty: String, data: HashMap<String, Value>, children: Vec<NestedBlock>) -> Self { + Self { ty, data, children } + } + + pub fn add_child(&mut self, child: NestedBlock) { + self.children.push(child); + } + + pub fn convert_to_html(&self, params: ConvertBlockToHtmlParams) -> String { + let mut html = String::new(); + + let text_html = self + .data + .get("delta") + .and_then(convert_insert_delta_from_json) + .map(|delta| delta_to_html(&delta)) + .unwrap_or_default(); + + let prev_block_ty = params.prev_block_ty.unwrap_or_default(); + let next_block_ty = params.next_block_ty.unwrap_or_default(); + + match self.ty.as_str() { + // <h1>Hello</h1> + HEADING => { + let level = self.data.get(LEVEL).unwrap_or(&Value::Null); + if level.as_u64().unwrap_or(0) > 6 { + html.push_str(&format!("<{}>{}</{}>", H6_TAG_NAME, text_html, H6_TAG_NAME)); + } else { + html.push_str(&format!("<h{}>{}</h{}>", level, text_html, level)); + } + }, + // <p>Hello</p> + PARAGRAPH => { + html.push_str(&format!("<{}>{}</{}>", P_TAG_NAME, text_html, P_TAG_NAME)); + html.push_str(&convert_nested_block_children_to_html(Arc::new( + self.to_owned(), + ))); + }, + // <aside>😁Hello</aside> + CALLOUT => { + html.push_str(&format!( + "<{}>{}{}</{}>", + ASIDE_TAG_NAME, + self + .data + .get(ICON) + .unwrap_or(&Value::Null) + .to_string() + .trim_matches('\"'), + text_html, + ASIDE_TAG_NAME + )); + }, + // <img src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" alt="Google Logo" /> + IMAGE => { + html.push_str(&format!( + "<{} src={} alt={} />", + IMG_TAG_NAME, + self.data.get(URL).unwrap(), + "AppFlowy-Image" + )); + }, + // <hr /> + DIVIDER => { + html.push_str(&format!("<{} />", HR_TAG_NAME)); + }, + // <p>$$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$</p> + MATH_EQUATION => { + let formula = self.data.get(FORMULA).unwrap_or(&Value::Null); + html.push_str(&format!( + "<{}>{}</{}>", + P_TAG_NAME, + formula.to_string().trim_matches('\"'), + P_TAG_NAME + )); + }, + // <pre><code class="language-js">console.log('Hello World!');</code></pre> + CODE => { + let language = self.data.get(LANGUAGE).unwrap_or(&Value::Null); + html.push_str(&format!( + "<{}><{} {}=\"{}-{}\">{}</{}></{}>", + PRE_TAG_NAME, + CODE_TAG_NAME, + CLASS, + LANGUAGE, + language.to_string().trim_matches('\"'), + text_html, + CODE_TAG_NAME, + PRE_TAG_NAME + )); + }, + // <details><summary>Hello</summary><p>World!</p></details> + TOGGLE_LIST => { + html.push_str(&format!("<{}>", DETAILS_TAG_NAME)); + html.push_str(&format!( + "<{}>{}</{}>", + SUMMARY_TAG_NAME, text_html, SUMMARY_TAG_NAME + )); + html.push_str(&convert_nested_block_children_to_html(Arc::new( + self.to_owned(), + ))); + html.push_str(&format!("</{}>", DETAILS_TAG_NAME)); + }, + // <ul><li>Hello</li><li>World!</li></ul> + BULLETED_LIST | NUMBERED_LIST | TODO_LIST => { + let list_type = if self.ty == NUMBERED_LIST { + OL_TAG_NAME + } else { + UL_TAG_NAME + }; + if prev_block_ty != self.ty { + html.push_str(&format!("<{}>", list_type)); + } + if self.ty == TODO_LIST { + let checked = self + .data + .get(CHECKED) + .and_then(|v| v.as_bool()) + .unwrap_or_default(); + // <li role="checkbox" aria-checked="true">Hello</li> + html.push_str(&format!( + "<{} {}=\"{}\" {}=\"{}\">{}", + LI_TAG_NAME, ROLE, CHECKBOX, ARIA_CHECKED, checked, text_html + )); + } else { + html.push_str(&format!("<{}>{}", LI_TAG_NAME, text_html)); + } + + html.push_str(&convert_nested_block_children_to_html(Arc::new( + self.to_owned(), + ))); + html.push_str(&format!("</{}>", LI_TAG_NAME)); + + if next_block_ty != self.ty { + html.push_str(&format!("</{}>", list_type)); + } + }, + + // <blockquote><p>Hello</p><p>World!</p></blockquote> + QUOTE => { + if prev_block_ty != self.ty { + html.push_str(&format!("<{}>", BLOCKQUOTE_TAG_NAME)); + } + html.push_str(&format!("<{}>{}</{}>", P_TAG_NAME, text_html, P_TAG_NAME)); + html.push_str(&convert_nested_block_children_to_html(Arc::new( + self.to_owned(), + ))); + if next_block_ty != self.ty { + html.push_str(&format!("</{}>", BLOCKQUOTE_TAG_NAME)); + } + }, + // <p>Hello</p> + PAGE => { + if !text_html.is_empty() { + html.push_str(&format!("<{}>{}</{}>", P_TAG_NAME, text_html, P_TAG_NAME)); + } + html.push_str(&convert_nested_block_children_to_html(Arc::new( + self.to_owned(), + ))); + }, + // <p>Hello</p> + _ => { + html.push_str(&format!("<{}>{}</{}>", P_TAG_NAME, text_html, P_TAG_NAME)); + html.push_str(&convert_nested_block_children_to_html(Arc::new( + self.to_owned(), + ))); + }, + }; + + html + } + + pub fn convert_to_text(&self) -> String { + let mut text = String::new(); + + let delta_text = self + .data + .get(DELTA) + .and_then(convert_insert_delta_from_json) + .map(|delta| delta_to_text(&delta)) + .unwrap_or_default(); + + match self.ty.as_str() { + CALLOUT => { + text.push_str(&format!( + "{}{}\n", + self + .data + .get(ICON) + .unwrap_or(&Value::Null) + .to_string() + .trim_matches('\"'), + delta_text + )); + }, + MATH_EQUATION => { + let formula = self.data.get(FORMULA).unwrap_or(&Value::Null); + text.push_str(&format!("{}\n", formula.to_string().trim_matches('\"'))); + }, + PAGE => { + if !delta_text.is_empty() { + text.push_str(&format!("{}\n", delta_text)); + } + for child in &self.children { + text.push_str(&child.convert_to_text()); + } + }, + _ => { + text.push_str(&format!("{}\n", delta_text)); + for child in &self.children { + text.push_str(&child.convert_to_text()); + } + }, + }; + text + } +} + +pub struct ConvertBlockToHtmlParams { + pub prev_block_ty: Option<String>, + pub next_block_ty: Option<String>, +} + +#[derive(PartialEq, Eq, Debug, ProtoBuf_Enum, Clone, Default)] +pub enum InputType { + #[default] + Html = 0, + PlainText = 1, +} + +#[derive(Default, ProtoBuf, Debug, Validate)] +pub struct ConvertDataToJsonPayloadPB { + #[pb(index = 1)] + #[validate(custom = "required_not_empty_str")] + pub data: String, + + #[pb(index = 2)] + pub input_type: InputType, +} + +pub struct ConvertDataToJsonParams { + pub data: String, + pub input_type: InputType, +} + +#[derive(Default, ProtoBuf, Debug)] +pub struct ConvertDataToJsonResponsePB { + #[pb(index = 1)] + pub json: String, +} + +impl TryInto<ConvertDataToJsonParams> for ConvertDataToJsonPayloadPB { + type Error = ErrorCode; + fn try_into(self) -> Result<ConvertDataToJsonParams, Self::Error> { + Ok(ConvertDataToJsonParams { + data: self.data, + input_type: self.input_type, + }) + } +} diff --git a/frontend/rust-lib/flowy-document2/src/parser/utils.rs b/frontend/rust-lib/flowy-document2/src/parser/utils.rs new file mode 100644 index 000000000000..e5365f222786 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/src/parser/utils.rs @@ -0,0 +1,126 @@ +use crate::parser::parser_entities::{ + ConvertBlockToHtmlParams, InsertDelta, NestedBlock, Selection, +}; +use collab_document::blocks::DocumentData; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use validator::ValidationError; + +pub fn get_delta_for_block(block_id: &str, data: &DocumentData) -> Option<Vec<InsertDelta>> { + let text_map = data.meta.text_map.as_ref()?; // Retrieve the text_map reference + + data.blocks.get(block_id).and_then(|block| { + let text_id = block.external_id.as_ref()?; + let delta_str = text_map.get(text_id)?; + serde_json::from_str::<Vec<InsertDelta>>(delta_str).ok() + }) +} + +pub fn get_delta_for_selection( + selection: &Selection, + data: &DocumentData, +) -> Option<Vec<InsertDelta>> { + let delta = get_delta_for_block(&selection.block_id, data)?; + let start = selection.index as usize; + let end = (selection.index + selection.length) as usize; + Some(slice_delta(&delta, start, end)) +} + +pub fn slice_delta(delta: &Vec<InsertDelta>, start: usize, end: usize) -> Vec<InsertDelta> { + let mut result = vec![]; + let mut index = 0; + for d in delta { + let content = &d.insert; + let text_len = content.len(); + // skip if index is not reached + if index + text_len <= start { + index += text_len; + continue; + } + // break if index is over end + if index >= end { + break; + } + // slice content, and push to result + let start_offset = std::cmp::max(0, start as isize - index as isize) as usize; + let end_offset = std::cmp::min(end - index, text_len); + let content = content[start_offset..end_offset].to_string(); + result.push(InsertDelta { + insert: content, + attributes: d.attributes.clone(), + }); + + index += text_len; + } + result +} +pub fn delta_to_text(delta: &Vec<InsertDelta>) -> String { + let mut result = String::new(); + for d in delta { + result.push_str(d.to_text().as_str()); + } + result +} + +pub fn delta_to_html(delta: &Vec<InsertDelta>) -> String { + let mut result = String::new(); + for d in delta { + result.push_str(d.to_html().as_str()); + } + result +} + +pub fn convert_nested_block_children_to_html(block: Arc<NestedBlock>) -> String { + let children = &block.children; + let mut html = String::new(); + let num_children = children.len(); + + for (i, child) in children.iter().enumerate() { + let prev_block_ty = if i > 0 { + Some(children[i - 1].ty.to_string()) + } else { + None + }; + + let next_block_ty = if i + 1 < num_children { + Some(children[i + 1].ty.to_string()) + } else { + None + }; + + let child_html = child.convert_to_html(ConvertBlockToHtmlParams { + prev_block_ty, + next_block_ty, + }); + + html.push_str(&child_html); + } + html +} + +pub fn convert_insert_delta_from_json(delta_value: &Value) -> Option<Vec<InsertDelta>> { + serde_json::from_value::<Vec<InsertDelta>>(delta_value.to_owned()).ok() +} + +pub fn required_not_empty_str(s: &str) -> Result<(), ValidationError> { + if s.is_empty() { + return Err(ValidationError::new("should not be empty string")); + } + Ok(()) +} + +pub fn serialize_color_attribute( + attrs: &HashMap<String, Value>, + attr_name: &str, + css_property: &str, +) -> String { + if let Some(color) = attrs.get(attr_name) { + return format!( + "{}: {};", + css_property, + color.to_string().replace("0x", "#").trim_matches('\"') + ); + } + "".to_string() +} diff --git a/frontend/rust-lib/flowy-document2/tests/assets/html/bulleted_list.html b/frontend/rust-lib/flowy-document2/tests/assets/html/bulleted_list.html new file mode 100644 index 000000000000..bad75cfbb819 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/html/bulleted_list.html @@ -0,0 +1 @@ +<meta charset="UTF-8"><ul><li>Highlight<p>You can also</p><ul><li>nest</li></ul></li></ul> \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/html/callout.html b/frontend/rust-lib/flowy-document2/tests/assets/html/callout.html new file mode 100644 index 000000000000..09c25736c709 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/html/callout.html @@ -0,0 +1,6 @@ +<meta charset="UTF-8"><aside>🥰 +Like AppFlowy? Follow us: +<a href="https://github.com/AppFlowy-IO/AppFlowy">GitHub</a> +<a href="https://twitter.com/appflowy">Twitter</a>: @appflowy +<a href="https://blog-appflowy.ghost.io/">Newsletter</a> +</aside> \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/html/code.html b/frontend/rust-lib/flowy-document2/tests/assets/html/code.html new file mode 100644 index 000000000000..9d859c1c5fc1 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/html/code.html @@ -0,0 +1,5 @@ +<meta charset="UTF-8"><pre><code class="language-rust">// This is the main function. +fn main() { + // Print text to the console. + println!("Hello World!"); +}</code></pre> \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/html/divider.html b/frontend/rust-lib/flowy-document2/tests/assets/html/divider.html new file mode 100644 index 000000000000..95ca67339a4e --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/html/divider.html @@ -0,0 +1 @@ +<meta charset="UTF-8"><hr /> \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/html/google_docs.html b/frontend/rust-lib/flowy-document2/tests/assets/html/google_docs.html new file mode 100644 index 000000000000..0de659e9ba01 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/html/google_docs.html @@ -0,0 +1 @@ +<meta charset='utf-8'><meta charset="utf-8"><b style="font-weight:normal;" id="docs-internal-guid-f2bdbd38-7fff-c014-2955-cf27ac87f53f"><h1 dir="ltr" style="line-height:1.38;margin-top:24pt;margin-bottom:6pt;"><span style="font-size:23pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:700;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">The Notion Document</span></h1><h1 dir="ltr" style="line-height:1.38;margin-top:24pt;margin-bottom:6pt;"><span style="font-size:23pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:700;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Heading-1</span></h1><h2 dir="ltr" style="line-height:1.38;margin-top:18pt;margin-bottom:4pt;"><span style="font-size:17pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:700;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Heading - 2</span></h2><h3 dir="ltr" style="line-height:1.38;margin-top:14pt;margin-bottom:4pt;"><span style="font-size:13pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:700;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Heading - 3</span></h3><h4 dir="ltr" style="line-height:1.38;margin-top:14pt;margin-bottom:4pt;"><span style="font-size:12pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:700;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Heading - 4</span></h4><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">This is a paragraph</span></p><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">paragraph’s child</span></p><ul style="margin-top:0;margin-bottom:0;padding-inline-start:48px;"><li dir="ltr" style="list-style-type:disc;font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="1"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:0pt;" role="presentation"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">This is a bulleted list - 1</span></p></li><ul style="margin-top:0;margin-bottom:0;padding-inline-start:48px;"><li dir="ltr" style="list-style-type:circle;font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="2"><p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;" role="presentation"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">This is a bulleted list - 1 - 1</span></p></li></ul><li dir="ltr" style="list-style-type:disc;font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="1"><p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:12pt;" role="presentation"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">This is a bulleted list - 2</span></p></li></ul><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">This is a paragraph</span></p><ul style="margin-top:0;margin-bottom:0;padding-inline-start:28px;"><li dir="ltr" role="checkbox" aria-checked="false" style="list-style-type:none;font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="1"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAA1ElEQVR4Ae3bMQ4BURSFYY2xBuwQ7BIkTGxFRj9Oo9RdkXn5TvL3L19u+2ZmZmZmZhVbpH26pFcaJ9IrndMudb/CWadHGiden1bll9MIzqd79SUd0thY20qga4NA50qgoUGgoRJo/NL/V/N+QIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIEyFeEZyXQpUGgUyXQrkGgTSVQl/qGcG5pnkq3Sn0jOMv0k3Vpm05pmNjfsGPalFyOmZmZmdkbSS9cKbtzhxMAAAAASUVORK5CYII=" width="17.599999999999998px" height="17.599999999999998px" alt="unticked" aria-roledescription="tick box" style="margin-right:3px;" /><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:0pt;display:inline-block;vertical-align:top;margin-top:0;" role="presentation"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">This is a todo - 1</span></p></li><ul style="margin-top:0;margin-bottom:0;padding-inline-start:28px;"><li dir="ltr" role="checkbox" aria-checked="false" style="list-style-type:none;font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="2"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAA1ElEQVR4Ae3bMQ4BURSFYY2xBuwQ7BIkTGxFRj9Oo9RdkXn5TvL3L19u+2ZmZmZmZhVbpH26pFcaJ9IrndMudb/CWadHGiden1bll9MIzqd79SUd0thY20qga4NA50qgoUGgoRJo/NL/V/N+QIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIEyFeEZyXQpUGgUyXQrkGgTSVQl/qGcG5pnkq3Sn0jOMv0k3Vpm05pmNjfsGPalFyOmZmZmdkbSS9cKbtzhxMAAAAASUVORK5CYII=" width="17.599999999999998px" height="17.599999999999998px" alt="unticked" aria-roledescription="tick box" style="margin-right:3px;" /><p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:12pt;display:inline-block;vertical-align:top;margin-top:0;" role="presentation"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">This is a todo - 1-1</span></p></li></ul></ul><p dir="ltr" style="line-height:1.38;margin-left: 72pt;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">This is a paragraph</span></p><ol style="margin-top:0;margin-bottom:0;padding-inline-start:48px;"><li dir="ltr" style="list-style-type:decimal;font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="1"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:0pt;" role="presentation"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">This is a numbered list -1</span></p></li><li dir="ltr" style="list-style-type:decimal;font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="1"><p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;" role="presentation"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">This is a numbered list -2</span></p></li><ol style="margin-top:0;margin-bottom:0;padding-inline-start:48px;"><li dir="ltr" style="list-style-type:lower-alpha;font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="2"><p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:12pt;" role="presentation"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">This is a numbered list-1-1</span></p></li></ol></ol><p dir="ltr" style="line-height:1.38;margin-left: 72pt;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">This is a paragraph</span></p><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">This is a paragraph</span><hr /></p><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">This is a paragraph </span><span style="font-size:11pt;font-family:Arial,sans-serif;color:#d9730d;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">font-color </span><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:#fbecdd;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">bg-color</span><span style="font-size:11pt;font-family:Arial,sans-serif;color:#37352f;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"> bold </span><span style="font-size:11pt;font-family:Arial,sans-serif;color:#37352f;background-color:transparent;font-weight:400;font-style:italic;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">italic underline </span><span style="font-size:11pt;font-family:Arial,sans-serif;color:#37352f;background-color:transparent;font-weight:400;font-style:italic;font-variant:normal;text-decoration:line-through;-webkit-text-decoration-skip:none;text-decoration-skip-ink:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">strike-through </span><span style="font-size:9.5pt;font-family:'Courier New',monospace;color:#37352f;background-color:transparent;font-weight:400;font-style:italic;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">inline-code</span><span style="font-size:11pt;font-family:Arial,sans-serif;color:#37352f;background-color:transparent;font-weight:400;font-style:italic;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"> inline-formula link</span></b> \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/html/heading.html b/frontend/rust-lib/flowy-document2/tests/assets/html/heading.html new file mode 100644 index 000000000000..99459dee6e46 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/html/heading.html @@ -0,0 +1 @@ +<meta charset="UTF-8"><h1>Heading1</h1><h2>Heading2</h2><h3>Heading3</h3> \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/html/image.html b/frontend/rust-lib/flowy-document2/tests/assets/html/image.html new file mode 100644 index 000000000000..24908700a51b --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/html/image.html @@ -0,0 +1 @@ +<meta charset="UTF-8"><img src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" alt=AppFlowy-Image /> \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/html/math_equation.html b/frontend/rust-lib/flowy-document2/tests/assets/html/math_equation.html new file mode 100644 index 000000000000..38f572ec822f --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/html/math_equation.html @@ -0,0 +1 @@ +<meta charset="UTF-8"><p>E = MC^2</p> \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/html/notion.html b/frontend/rust-lib/flowy-document2/tests/assets/html/notion.html new file mode 100644 index 000000000000..ebd0b8eb3f26 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/html/notion.html @@ -0,0 +1,34 @@ +<meta charset='utf-8'><h1>The Notion Document</h1> +<h1>Heading-1</h1> +<h2>Heading - 2</h2> +<h3>Heading - 3</h3> +<p>This is a paragraph</p> +<p>paragraph’s child</p> +<ul><li>This is a bulleted list - 1<ul><li>This is a bulleted list - 1 - 1</li></ul></li><li>This is a bulleted list - 2</li></ul> +<p>This is a paragraph</p> +<ul><li>[ ] This is a todo - 1<ul><li>[ ] This is a paragraph - 1-1</li></ul></li></ul> +<ol><li>This is a numbered list -1</li></ol> +<p>This is a paragraph</p> +<ul><li><p>This is a toggle list</p><p>This is a toggle child</p></li> +</ul> +<blockquote><p>This is a quote</p><p>This is a quote child</p></blockquote> +<p>This is a paragraph</p> +<hr> +<pre><code class="language-jsx">// This is the main function. +fn main() { + // Print text to the console. + **println**!("Hello World!"); +}</code></pre> +<p>This is a paragraph</p> +<p><aside> + 💡 callout</p> +<p></aside></p> +<p>This is a paragraph font-color bg-color <strong>bold</strong> <em>italic underline <s>strike-through</s> <code>inline-code</code> $inline-formula$ <a href="https://www.notion.so/The-Notion-Document-d4236da306b84f6199e4091705042d78?pvs=21">link</a></em></p> +<p>$$ + |x| = \begin{cases} + x, &\quad x \geq 0 \\ + -x, &\quad x < 0 + \end{cases} + $$</p> +<p>End</p> +<!-- notionvc: 0b0229d7-b98a-4e36-8a64-f944de21ef0e --> \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/html/numbered_list.html b/frontend/rust-lib/flowy-document2/tests/assets/html/numbered_list.html new file mode 100644 index 000000000000..d4e8134c029e --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/html/numbered_list.html @@ -0,0 +1 @@ +<meta charset="UTF-8"><ol><li>Highlight<p>You can also</p><ol><li>nest</li></ol></li></ol> \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/html/paragraph.html b/frontend/rust-lib/flowy-document2/tests/assets/html/paragraph.html new file mode 100644 index 000000000000..786d48fa5060 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/html/paragraph.html @@ -0,0 +1,6 @@ +<meta charset="UTF-8"><p> +Like AppFlowy? Follow us: +<a href="https://github.com/AppFlowy-IO/AppFlowy">GitHub</a> +<a href="https://twitter.com/appflowy">Twitter</a>: @appflowy +<a href="https://blog-appflowy.ghost.io/">Newsletter</a> +</p><p>Click <code>?</code> at the bottom right for help and support.</p><p><span style="background-color: #4dffeb3b;">Highlight </span>any text, and use the editing menu to <span style="font-style: italic;">style</span> <span style="font-weight: bold;">your</span> <span style="text-decoration: underline;">writing</span> <code>however</code><span style="color: #4dffeb3b;"> you </span><span style="text-decoration: line-through;">like.</span><span style="font-family: fantasy;">1+1=2</span></p> \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/html/quote.html b/frontend/rust-lib/flowy-document2/tests/assets/html/quote.html new file mode 100644 index 000000000000..6da59e8aeb27 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/html/quote.html @@ -0,0 +1 @@ +<meta charset="UTF-8"><blockquote><p>This is a quote</p><p>This is a paragraph</p></blockquote> \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/html/todo_list.html b/frontend/rust-lib/flowy-document2/tests/assets/html/todo_list.html new file mode 100644 index 000000000000..46dcabd198e6 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/html/todo_list.html @@ -0,0 +1 @@ +<meta charset="UTF-8"><ul><li role="checkbox" aria-checked="true">Highlight<p>You can also</p><ul><li role="checkbox" aria-checked="false">nest</li></ul></li></ul> \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/html/toggle_list.html b/frontend/rust-lib/flowy-document2/tests/assets/html/toggle_list.html new file mode 100644 index 000000000000..11df3f80b0a2 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/html/toggle_list.html @@ -0,0 +1 @@ +<meta charset="UTF-8"><details><summary>Click <code>?</code> at the bottom right for help and support.</summary><p>This is a paragraph</p><details><summary>This is a toggle list</summary></details></details> \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/bulleted_list.json b/frontend/rust-lib/flowy-document2/tests/assets/json/bulleted_list.json new file mode 100644 index 000000000000..47080498057c --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/bulleted_list.json @@ -0,0 +1,37 @@ +{ + "type": "page", + "children": [ + { + "type": "bulleted_list", + "data": { + "delta": [ + { + "insert": "Highlight" + } + ] + }, + "children": [ + { + "type": "paragraph", + "data": { + "delta": [ + { + "insert": "You can also" + } + ] + } + }, + { + "type": "bulleted_list", + "data": { + "delta": [ + { + "insert": "nest" + } + ] + } + } + ] + } + ] +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/callout.json b/frontend/rust-lib/flowy-document2/tests/assets/json/callout.json new file mode 100644 index 000000000000..a494982f6472 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/callout.json @@ -0,0 +1,32 @@ +{ + "type": "page", + "data": {}, + "children": [ + { + "type": "callout", + "data": { + "delta": [ + { "insert": "\nLike AppFlowy? Follow us:\n" }, + { + "attributes": { + "href": "https://github.com/AppFlowy-IO/AppFlowy" + }, + "insert": "GitHub" + }, + { "insert": "\n" }, + { + "attributes": { "href": "https://twitter.com/appflowy" }, + "insert": "Twitter" + }, + { "insert": ": @appflowy\n" }, + { + "attributes": { "href": "https://blog-appflowy.ghost.io/" }, + "insert": "Newsletter" + }, + { "insert": "\n" } + ], + "icon": "🥰" + } + } + ] +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/code.json b/frontend/rust-lib/flowy-document2/tests/assets/json/code.json new file mode 100644 index 000000000000..21bf6379077f --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/code.json @@ -0,0 +1,14 @@ +{ + "type": "page", + "children": [{ + "type": "code", + "data": { + "language": "rust", + "delta": [ + { + "insert": "// This is the main function.\nfn main() {\n // Print text to the console.\n println!(\"Hello World!\");\n}" + } + ] + } + }] +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/divider.json b/frontend/rust-lib/flowy-document2/tests/assets/json/divider.json new file mode 100644 index 000000000000..05625723f301 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/divider.json @@ -0,0 +1,10 @@ +{ + "type": "page", + "data": {}, + "children": [ + { + "type": "divider", + "data": {} + } + ] +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/google_docs.json b/frontend/rust-lib/flowy-document2/tests/assets/json/google_docs.json new file mode 100644 index 000000000000..27aa86f46211 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/google_docs.json @@ -0,0 +1,351 @@ +{ + "children": [ + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "The Notion Document" + } + ], + "level": 1, + "text_direction": "ltr" + }, + "type": "heading" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "Heading-1" + } + ], + "level": 1, + "text_direction": "ltr" + }, + "type": "heading" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "Heading - 2" + } + ], + "level": 2, + "text_direction": "ltr" + }, + "type": "heading" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "Heading - 3" + } + ], + "level": 3, + "text_direction": "ltr" + }, + "type": "heading" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "Heading - 4" + } + ], + "level": 3, + "text_direction": "ltr" + }, + "type": "heading" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "This is a paragraph" + } + ], + "text_direction": "ltr" + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "paragraph’s child" + } + ], + "text_direction": "ltr" + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "This is a bulleted list - 1" + } + ] + }, + "type": "bulleted_list" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "This is a bulleted list - 1 - 1" + } + ] + }, + "type": "bulleted_list" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "This is a bulleted list - 2" + } + ] + }, + "type": "bulleted_list" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "This is a paragraph" + } + ], + "text_direction": "ltr" + }, + "type": "paragraph" + }, + { + "children": [ + { + "children": [], + "data": { + "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAA1ElEQVR4Ae3bMQ4BURSFYY2xBuwQ7BIkTGxFRj9Oo9RdkXn5TvL3L19u+2ZmZmZmZhVbpH26pFcaJ9IrndMudb/CWadHGiden1bll9MIzqd79SUd0thY20qga4NA50qgoUGgoRJo/NL/V/N+QIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIEyFeEZyXQpUGgUyXQrkGgTSVQl/qGcG5pnkq3Sn0jOMv0k3Vpm05pmNjfsGPalFyOmZmZmdkbSS9cKbtzhxMAAAAASUVORK5CYII=" + }, + "type": "image" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "This is a todo - 1" + } + ], + "text_direction": "ltr" + }, + "type": "paragraph" + } + ], + "data": { + "checked": false, + "text_direction": "ltr" + }, + "type": "todo_list" + }, + { + "children": [ + { + "children": [], + "data": { + "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAA1ElEQVR4Ae3bMQ4BURSFYY2xBuwQ7BIkTGxFRj9Oo9RdkXn5TvL3L19u+2ZmZmZmZhVbpH26pFcaJ9IrndMudb/CWadHGiden1bll9MIzqd79SUd0thY20qga4NA50qgoUGgoRJo/NL/V/N+QIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIEyFeEZyXQpUGgUyXQrkGgTSVQl/qGcG5pnkq3Sn0jOMv0k3Vpm05pmNjfsGPalFyOmZmZmdkbSS9cKbtzhxMAAAAASUVORK5CYII=" + }, + "type": "image" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "This is a todo - 1-1" + } + ], + "text_direction": "ltr" + }, + "type": "paragraph" + } + ], + "data": { + "checked": false, + "text_direction": "ltr" + }, + "type": "todo_list" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "This is a paragraph" + } + ], + "text_direction": "ltr" + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "This is a numbered list -1" + } + ] + }, + "type": "numbered_list" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "This is a numbered list -2" + } + ] + }, + "type": "numbered_list" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "This is a numbered list-1-1" + } + ] + }, + "type": "numbered_list" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "This is a paragraph" + } + ], + "text_direction": "ltr" + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": { + "bg_color": "transparent", + "font_color": "#000000" + }, + "insert": "This is a paragraph" + } + ], + "text_direction": "ltr" + }, + "type": "paragraph" + }, + { + "children": [], + "data": {}, + "type": "divider" + }, + { + "children": [], + "data": {}, + "type": "paragraph" + } + ], + "data": {}, + "type": "page" +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/heading.json b/frontend/rust-lib/flowy-document2/tests/assets/json/heading.json new file mode 100644 index 000000000000..a1c9ffcb783f --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/heading.json @@ -0,0 +1,39 @@ +{ + "type": "page", + "data": {}, + "children": [ + { + "type": "heading", + "data": { + "level": 1, + "delta": [ + { + "insert": "Heading1" + } + ] + } + }, + { + "type": "heading", + "data": { + "level": 2, + "delta": [ + { + "insert": "Heading2" + } + ] + } + }, + { + "type": "heading", + "data": { + "level": 3, + "delta": [ + { + "insert": "Heading3" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/image.json b/frontend/rust-lib/flowy-document2/tests/assets/json/image.json new file mode 100644 index 000000000000..0b88529538a5 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/image.json @@ -0,0 +1,15 @@ +{ + "type": "page", + "data": {}, + "children": [ + { + "type": "image", + "data": { + "url": "https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png", + "width": 272, + "height": 92, + "align": "center" + } + } + ] +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/initial_document.json b/frontend/rust-lib/flowy-document2/tests/assets/json/initial_document.json new file mode 100644 index 000000000000..5ef653ad9528 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/initial_document.json @@ -0,0 +1,267 @@ +{ + "type": "page", + "data": {}, + "children": [ + { + "type": "heading", + "data": { "delta": [{ "insert": "Welcome to AppFlowy!" }], "level": 1 } + }, + { + "type": "heading", + "data": { "delta": [{ "insert": "Here are the basics" }], "level": 2 } + }, + { + "type": "heading", + "data": { "delta": [{ "insert": "Here is H3" }], "level": 3 } + }, + { + "type": "todo_list", + "data": { + "delta": [{ "insert": "Click anywhere and just start typing." }], + "checked": false + }, + "children": [ + { + "type": "todo_list", + "data": { + "delta": [ + { "insert": "Click " }, + { "attributes": { "code": true }, "insert": "Enter" }, + { "insert": " to create a new line." } + ], + "checked": false + } + } + ] + }, + { + "type": "todo_list", + "data": { + "checked": false, + "delta": [ + { + "attributes": { "bg_color": "0x4dffeb3b" }, + "insert": "Highlight " + }, + { "insert": "any text, and use the editing menu to " }, + { "attributes": { "italic": true }, "insert": "style" }, + { "insert": " " }, + { "attributes": { "bold": true }, "insert": "your" }, + { "insert": " " }, + { "attributes": { "underline": true }, "insert": "writing" }, + { "insert": " " }, + { "attributes": { "code": true }, "insert": "however" }, + { "insert": " you " }, + { "attributes": { "strikethrough": true }, "insert": "like." } + ] + } + }, + { + "type": "todo_list", + "data": { + "checked": false, + "delta": [ + { "insert": "As soon as you type " }, + { + "attributes": { "code": true, "font_color": "0xff00b5ff" }, + "insert": "/" + }, + { "insert": " a menu will pop up. Select " }, + { + "attributes": { "bg_color": "0x4d9c27b0" }, + "insert": "different types" + }, + { "insert": " of content blocks you can add." } + ] + } + }, + { + "type": "todo_list", + "data": { + "delta": [ + { "insert": "Type " }, + { "attributes": { "code": true }, "insert": "/" }, + { "insert": " followed by " }, + { "attributes": { "code": true }, "insert": "/bullet" }, + { "insert": " or " }, + { "attributes": { "code": true }, "insert": "/num" }, + { "attributes": { "code": false }, "insert": " to create a list." } + ], + "checked": false + } + }, + { + "type": "todo_list", + "data": { + "delta": [ + { "insert": "Click " }, + { "attributes": { "code": true }, "insert": "+ New Page " }, + { + "insert": "button at the bottom of your sidebar to add a new page." + } + ], + "checked": true + } + }, + { + "type": "todo_list", + "data": { + "checked": false, + "delta": [ + { "insert": "Click " }, + { "attributes": { "code": true }, "insert": "+" }, + { "insert": " next to any page title in the sidebar to " }, + { + "attributes": { "font_color": "0xff8427e0" }, + "insert": "quickly" + }, + { "insert": " add a new subpage, " }, + { "attributes": { "code": true }, "insert": "Document" }, + { "attributes": { "code": false }, "insert": ", " }, + { "attributes": { "code": true }, "insert": "Grid" }, + { "attributes": { "code": false }, "insert": ", or " }, + { "attributes": { "code": true }, "insert": "Kanban Board" }, + { "attributes": { "code": false }, "insert": "." } + ] + } + }, + { "type": "paragraph", "data": { "delta": [] } }, + { "type": "divider" }, + { "type": "paragraph", "data": { "delta": [] } }, + { + "type": "heading", + "data": { + "delta": [{ "insert": "Keyboard shortcuts, markdown, and code block" }], + "level": 2 + } + }, + { + "type": "numbered_list", + "data": { + "delta": [ + { "insert": "Keyboard shortcuts " }, + { + "attributes": { + "href": "https://appflowy.gitbook.io/docs/essential-documentation/shortcuts" + }, + "insert": "guide" + } + ] + } + }, + { + "type": "numbered_list", + "data": { + "delta": [ + { "insert": "Markdown " }, + { + "attributes": { + "href": "https://appflowy.gitbook.io/docs/essential-documentation/markdown" + }, + "insert": "reference" + } + ] + } + }, + { + "type": "numbered_list", + "data": { + "delta": [ + { "insert": "Type " }, + { "attributes": { "code": true }, "insert": "/code" }, + { + "attributes": { "code": false }, + "insert": " to insert a code block" + } + ] + } + }, + { + "type": "code", + "data": { + "language": "rust", + "delta": [ + { + "insert": "// This is the main function.\nfn main() {\n // Print text to the console.\n println!(\"Hello World!\");\n}" + } + ] + } + }, + { + "type": "paragraph", + "data": { "delta": [] }, + "children": [{ + "type": "paragraph", + "data": { "delta": [{ "insert": "This is a paragraph" }] }, + "children": [{ + "type": "paragraph", + "data": { "delta": [{ "insert": "This is a paragraph" }] } + }] + }] + }, + { + "type": "heading", + "data": { "level": 2, "delta": [{ "insert": "Have a question❓" }] } + }, + { + "type": "toggle_list", + "data": { + "delta": [ + { "insert": "Click " }, + { "attributes": { "code": true }, "insert": "?" }, + { "insert": " at the bottom right for help and support." } + ] + }, + "children": [ + { + "type": "paragraph", + "data": { "delta": [{ "insert": "This is a paragraph" }] } + }, + { + "type": "paragraph", + "data": { "delta": [{ "insert": "This is a paragraph" }] } + } + ] + }, + { + "type": "quote", + "data": { + "delta": [ + { "insert": "Click " }, + { "attributes": { "code": true }, "insert": "?" }, + { "insert": " at the bottom right for help and support." } + ] + } + }, + { "type": "paragraph", "data": { "delta": [] } }, + { + "type": "callout", + "data": { + "delta": [ + { "insert": "\nLike AppFlowy? Follow us:\n" }, + { + "attributes": { + "href": "https://github.com/AppFlowy-IO/AppFlowy" + }, + "insert": "GitHub" + }, + { "insert": "\n" }, + { + "attributes": { "href": "https://twitter.com/appflowy" }, + "insert": "Twitter" + }, + { "insert": ": @appflowy\n" }, + { + "attributes": { "href": "https://blog-appflowy.ghost.io/" }, + "insert": "Newsletter" + }, + { "insert": "\n" } + ], + "icon": "🥰" + } + }, + { "type": "paragraph", "data": { "delta": [] } }, + { "type": "paragraph", "data": { "delta": [] } }, + { "type": "paragraph", "data": { "delta": [] } } + ] +} diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/math_equation.json b/frontend/rust-lib/flowy-document2/tests/assets/json/math_equation.json new file mode 100644 index 000000000000..8d1fd5245639 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/math_equation.json @@ -0,0 +1,9 @@ +{ + "type": "page", + "children": [{ + "type": "math_equation", + "data": { + "formula": "E = MC^2" + } + }] +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/notion.json b/frontend/rust-lib/flowy-document2/tests/assets/json/notion.json new file mode 100644 index 000000000000..0e5f83fd1349 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/notion.json @@ -0,0 +1,371 @@ +{ + "type": "page", + "data": {}, + "children": [ + { + "type": "heading", + "data": { + "delta": [ + { + "attributes": null, + "insert": "The Notion Document" + } + ], + "level": 1 + }, + "children": [] + }, + { + "type": "heading", + "data": { + "level": 1, + "delta": [ + { + "attributes": null, + "insert": "Heading-1" + } + ] + }, + "children": [] + }, + { + "type": "heading", + "data": { + "level": 2, + "delta": [ + { + "attributes": null, + "insert": "Heading - 2" + } + ] + }, + "children": [] + }, + { + "type": "heading", + "data": { + "level": 3, + "delta": [ + { + "attributes": null, + "insert": "Heading - 3" + } + ] + }, + "children": [] + }, + { + "type": "paragraph", + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a paragraph" + } + ] + }, + "children": [] + }, + { + "type": "paragraph", + "data": { + "delta": [ + { + "attributes": null, + "insert": "paragraph’s child" + } + ] + }, + "children": [] + }, + { + "type": "bulleted_list", + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a bulleted list - 1" + } + ] + }, + "children": [ + { + "type": "bulleted_list", + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a bulleted list - 1 - 1" + } + ] + }, + "children": [] + } + ] + }, + { + "type": "bulleted_list", + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a bulleted list - 2" + } + ] + }, + "children": [] + }, + { + "type": "paragraph", + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a paragraph" + } + ] + }, + "children": [] + }, + { + "type": "bulleted_list", + "data": { + "delta": [ + { + "attributes": null, + "insert": "[ ] This is a todo - 1" + } + ] + }, + "children": [ + { + "type": "bulleted_list", + "data": { + "delta": [ + { + "attributes": null, + "insert": "[ ] This is a paragraph - 1-1" + } + ] + }, + "children": [] + } + ] + }, + { + "type": "numbered_list", + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a numbered list -1" + } + ] + }, + "children": [] + }, + { + "type": "paragraph", + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a paragraph" + } + ] + }, + "children": [] + }, + { + "type": "bulleted_list", + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a toggle list" + } + ] + }, + "children": [ + { + "type": "paragraph", + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a toggle child" + } + ] + }, + "children": [] + } + ] + }, + { + "type": "quote", + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a quote" + } + ] + }, + "children": [ + { + "type": "paragraph", + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a quote child" + } + ] + }, + "children": [] + } + ] + }, + { + "type": "paragraph", + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a paragraph" + } + ] + }, + "children": [] + }, + { + "type": "divider", + "data": {}, + "children": [] + }, + { + "type": "code", + "data": { + "delta": [ + { + "attributes": null, + "insert": "// This is the main function.\nfn main() {\n // Print text to the console.\n **println**!(\"Hello World!\");\n}" + } + ], + "language": "jsx" + }, + "children": [] + }, + { + "type": "paragraph", + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a paragraph" + } + ] + }, + "children": [] + }, + { + "type": "paragraph", + "data": { + "delta": [ + { + "attributes": null, + "insert": "<aside>\n 💡 callout" + } + ] + }, + "children": [] + }, + { + "type": "paragraph", + "data": { + "delta": [ + { + "attributes": null, + "insert": "</aside>" + } + ] + }, + "children": [] + }, + { + "type": "paragraph", + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a paragraph font-color bg-color " + }, + { + "attributes": { + "bold": true + }, + "insert": "bold" + }, + { + "attributes": { + "italic": true + }, + "insert": "italic underline " + }, + { + "attributes": { + "italic": true, + "strikethrough": true + }, + "insert": "strike-through" + }, + { + "attributes": { + "code": true, + "italic": true + }, + "insert": "inline-code" + }, + { + "attributes": { + "italic": true + }, + "insert": " $inline-formula$ " + }, + { + "attributes": { + "href": "https://www.notion.so/The-Notion-Document-d4236da306b84f6199e4091705042d78?pvs=21", + "italic": true + }, + "insert": "link" + } + ] + }, + "children": [] + }, + { + "type": "paragraph", + "data": { + "delta": [ + { + "attributes": null, + "insert": "$$\n |x| = \\begin{cases}\n x, &\\quad x \\geq 0 \\\\\n -x, &\\quad x < 0\n \\end{cases}\n $$" + } + ] + }, + "children": [] + }, + { + "type": "paragraph", + "data": { + "delta": [ + { + "attributes": null, + "insert": "End" + } + ] + }, + "children": [] + } + ] +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/numbered_list.json b/frontend/rust-lib/flowy-document2/tests/assets/json/numbered_list.json new file mode 100644 index 000000000000..cdcea264c738 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/numbered_list.json @@ -0,0 +1,37 @@ +{ + "type": "page", + "children": [ + { + "type": "numbered_list", + "data": { + "delta": [ + { + "insert": "Highlight" + } + ] + }, + "children": [ + { + "type": "paragraph", + "data": { + "delta": [ + { + "insert": "You can also" + } + ] + } + }, + { + "type": "numbered_list", + "data": { + "delta": [ + { + "insert": "nest" + } + ] + } + } + ] + } + ] +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/paragraph.json b/frontend/rust-lib/flowy-document2/tests/assets/json/paragraph.json new file mode 100644 index 000000000000..50aac23910d8 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/paragraph.json @@ -0,0 +1,59 @@ +{ + "type": "page", + "data": {}, + "children": [ + { + "type": "paragraph", + "data": { "delta": [ + { "insert": "\nLike AppFlowy? Follow us:\n" }, + { + "attributes": { + "href": "https://github.com/AppFlowy-IO/AppFlowy" + }, + "insert": "GitHub" + }, + { "insert": "\n" }, + { + "attributes": { "href": "https://twitter.com/appflowy" }, + "insert": "Twitter" + }, + { "insert": ": @appflowy\n" }, + { + "attributes": { "href": "https://blog-appflowy.ghost.io/" }, + "insert": "Newsletter" + }, + { "insert": "\n" } + ]}, + "children": [{ + "type": "paragraph", + "data": { + "delta": [ + { "insert": "Click " }, + { "attributes": { "code": true }, "insert": "?" }, + { "insert": " at the bottom right for help and support." } + ] + } + }] + }, + { + "type": "paragraph", + "data": { "delta": [ + { + "attributes": { "bg_color": "0x4dffeb3b" }, + "insert": "Highlight " + }, + { "insert": "any text, and use the editing menu to " }, + { "attributes": { "italic": true }, "insert": "style" }, + { "insert": " " }, + { "attributes": { "bold": true }, "insert": "your" }, + { "insert": " " }, + { "attributes": { "underline": true }, "insert": "writing" }, + { "insert": " " }, + { "attributes": { "code": true }, "insert": "however" }, + { "insert": " you ", "attributes": { "font_color": "0x4dffeb3b" } }, + { "attributes": { "strikethrough": true }, "insert": "like." }, + { "attributes": { "formula": true }, "insert": "1+1=2" } + ] } + } + ] +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/plain_text.json b/frontend/rust-lib/flowy-document2/tests/assets/json/plain_text.json new file mode 100644 index 000000000000..33d86667e05b --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/plain_text.json @@ -0,0 +1,510 @@ +{ + "children": [ + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "# The Notion Document" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "# Heading-1" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "## Heading - 2" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "### Heading - 3" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a paragraph" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "paragraph’s child" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "- This is a bulleted list - 1" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": " - This is a bulleted list - 1 - 1" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "- This is a bulleted list - 2" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a paragraph" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "- [ ] This is a todo - 1" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": " - [ ] This is a paragraph - 1-1" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "1. This is a numbered list -1" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a paragraph" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "- This is a toggle list" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": " This is a toggle child" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "> This is a quote" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": ">" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": ">" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "> This is a quote child" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": ">" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a paragraph" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "---" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "```jsx" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "// This is the main function." + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "fn main() {" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": " // Print text to the console." + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": " **println**!(\"Hello World!\");" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "}" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "```" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a paragraph" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "<aside>" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "💡 callout" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "</aside>" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "This is a paragraph font-color bg-color **bold** *italic underline ~~strike-through~~ `inline-code` $inline-formula$ [link](https://www.notion.so/The-Notion-Document-d4236da306b84f6199e4091705042d78?pvs=21)*" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "$$" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "|x| = \\begin{cases}             " + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "  x, &\\quad x \\geq 0 \\\\           " + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": " -x, &\\quad x < 0             " + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "\\end{cases}" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "$$" + } + ] + }, + "type": "paragraph" + }, + { + "children": [], + "data": { + "delta": [ + { + "attributes": null, + "insert": "End" + } + ] + }, + "type": "paragraph" + } + ], + "data": {}, + "type": "page" +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/quote.json b/frontend/rust-lib/flowy-document2/tests/assets/json/quote.json new file mode 100644 index 000000000000..a17f3d55b78d --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/quote.json @@ -0,0 +1,25 @@ +{ + "type": "page", + "children": [ + { + "type": "quote", + "data": { + "delta": [ + { + "insert": "This is a quote" + } + ] + }, + "children": [{ + "type": "paragraph", + "data": { + "delta": [ + { + "insert": "This is a paragraph" + } + ] + } + }] + } + ] +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/range_1.json b/frontend/rust-lib/flowy-document2/tests/assets/json/range_1.json new file mode 100644 index 000000000000..778a5e5f1ddd --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/range_1.json @@ -0,0 +1,100 @@ +{ + "type": "page", + "data": {}, + "children": [ + { + "type": "heading", + "data": { "delta": [{ "insert": " are the basics" }], "level": 2 } + }, + { + "type": "heading", + "data": { "delta": [{ "insert": "Here is H3" }], "level": 3 } + }, + { + "type": "todo_list", + "data": { + "delta": [{ "insert": "Click anywhere and just start typing." }], + "checked": false + }, + "children": [ + { + "type": "todo_list", + "data": { + "delta": [ + { "insert": "Click " }, + { "attributes": { "code": true }, "insert": "Enter" }, + { "insert": " to create a new line." } + ], + "checked": false + } + } + ] + }, + { + "type": "todo_list", + "data": { + "checked": false, + "delta": [ + { + "attributes": { "bg_color": "0x4dffeb3b" }, + "insert": "Highlight " + }, + { "insert": "any text, and use the editing menu to " }, + { "attributes": { "italic": true }, "insert": "style" }, + { "insert": " " }, + { "attributes": { "bold": true }, "insert": "your" }, + { "insert": " " }, + { "attributes": { "underline": true }, "insert": "writing" }, + { "insert": " " }, + { "attributes": { "code": true }, "insert": "however" }, + { "insert": " you " }, + { "attributes": { "strikethrough": true }, "insert": "like." } + ] + } + }, + { + "type": "todo_list", + "data": { + "checked": false, + "delta": [ + { "insert": "As soon as you type " }, + { + "attributes": { "code": true, "font_color": "0xff00b5ff" }, + "insert": "/" + }, + { "insert": " a menu will pop up. Select " }, + { + "attributes": { "bg_color": "0x4d9c27b0" }, + "insert": "different types" + }, + { "insert": " of content blocks you can add." } + ] + } + }, + { + "type": "todo_list", + "data": { + "delta": [ + { "insert": "Type " }, + { "attributes": { "code": true }, "insert": "/" }, + { "insert": " followed by " }, + { "attributes": { "code": true }, "insert": "/bullet" }, + { "insert": " or " }, + { "attributes": { "code": true }, "insert": "/num" }, + { "attributes": { "code": false }, "insert": " to create a list." } + ], + "checked": false + } + }, + { + "type": "todo_list", + "data": { + "delta": [ + { "insert": "Click " }, + { "attributes": { "code": true }, "insert": "+ New" } + ], + "checked": true + } + } + ] +} diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/range_2.json b/frontend/rust-lib/flowy-document2/tests/assets/json/range_2.json new file mode 100644 index 000000000000..3e0740427453 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/range_2.json @@ -0,0 +1,178 @@ +{ + "type": "page", + "data": {}, + "children": [ + { + "type": "todo_list", + "data": { + "delta": [ + { "attributes": { "code": true }, "insert": "Enter" }, + { "insert": " to create a new line." } + ], + "checked": false + } + }, + { + "type": "todo_list", + "data": { + "checked": false, + "delta": [ + { + "attributes": { "bg_color": "0x4dffeb3b" }, + "insert": "Highlight " + }, + { "insert": "any text, and use the editing menu to " }, + { "attributes": { "italic": true }, "insert": "style" }, + { "insert": " " }, + { "attributes": { "bold": true }, "insert": "your" }, + { "insert": " " }, + { "attributes": { "underline": true }, "insert": "writing" }, + { "insert": " " }, + { "attributes": { "code": true }, "insert": "however" }, + { "insert": " you " }, + { "attributes": { "strikethrough": true }, "insert": "like." } + ] + } + }, + { + "type": "todo_list", + "data": { + "checked": false, + "delta": [ + { "insert": "As soon as you type " }, + { + "attributes": { "code": true, "font_color": "0xff00b5ff" }, + "insert": "/" + }, + { "insert": " a menu will pop up. Select " }, + { + "attributes": { "bg_color": "0x4d9c27b0" }, + "insert": "different types" + }, + { "insert": " of content blocks you can add." } + ] + } + }, + { + "type": "todo_list", + "data": { + "delta": [ + { "insert": "Type " }, + { "attributes": { "code": true }, "insert": "/" }, + { "insert": " followed by " }, + { "attributes": { "code": true }, "insert": "/bullet" }, + { "insert": " or " }, + { "attributes": { "code": true }, "insert": "/num" }, + { "attributes": { "code": false }, "insert": " to create a list." } + ], + "checked": false + } + }, + { + "type": "todo_list", + "data": { + "delta": [ + { "insert": "Click " }, + { "attributes": { "code": true }, "insert": "+ New Page " }, + { + "insert": "button at the bottom of your sidebar to add a new page." + } + ], + "checked": true + } + }, + { + "type": "todo_list", + "data": { + "checked": false, + "delta": [ + { "insert": "Click " }, + { "attributes": { "code": true }, "insert": "+" }, + { "insert": " next to any page title in the sidebar to " }, + { + "attributes": { "font_color": "0xff8427e0" }, + "insert": "quickly" + }, + { "insert": " add a new subpage, " }, + { "attributes": { "code": true }, "insert": "Document" }, + { "attributes": { "code": false }, "insert": ", " }, + { "attributes": { "code": true }, "insert": "Grid" }, + { "attributes": { "code": false }, "insert": ", or " }, + { "attributes": { "code": true }, "insert": "Kanban Board" }, + { "attributes": { "code": false }, "insert": "." } + ] + } + }, + { "type": "paragraph", "data": { "delta": [] } }, + { "type": "divider" }, + { "type": "paragraph", "data": { "delta": [] } }, + { + "type": "heading", + "data": { + "delta": [{ "insert": "Keyboard shortcuts, markdown, and code block" }], + "level": 2 + } + }, + { + "type": "numbered_list", + "data": { + "delta": [ + { "insert": "Keyboard shortcuts " }, + { + "attributes": { + "href": "https://appflowy.gitbook.io/docs/essential-documentation/shortcuts" + }, + "insert": "guide" + } + ] + } + }, + { + "type": "numbered_list", + "data": { + "delta": [ + { "insert": "Markdown " }, + { + "attributes": { + "href": "https://appflowy.gitbook.io/docs/essential-documentation/markdown" + }, + "insert": "reference" + } + ] + } + }, + { + "type": "numbered_list", + "data": { + "delta": [ + { "insert": "Type " }, + { "attributes": { "code": true }, "insert": "/code" }, + { + "attributes": { "code": false }, + "insert": " to insert a code block" + } + ] + } + }, + { + "type": "code", + "data": { + "language": "rust", + "delta": [ + { + "insert": "// This is the main function.\nfn main() {\n // Print text to the console.\n println!(\"Hello World!\");\n}" + } + ] + } + }, + { + "type": "paragraph", + "data": { "delta": [] }, + "children": [{ + "type": "paragraph", + "data": { "delta": [{ "insert": "This is a p" }] }, + "children": [] + }] + } + ] +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/todo_list.json b/frontend/rust-lib/flowy-document2/tests/assets/json/todo_list.json new file mode 100644 index 000000000000..e37e103af367 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/todo_list.json @@ -0,0 +1,39 @@ +{ + "type": "page", + "children": [ + { + "type": "todo_list", + "data": { + "checked": true, + "delta": [ + { + "insert": "Highlight" + } + ] + }, + "children": [ + { + "type": "paragraph", + "data": { + "delta": [ + { + "insert": "You can also" + } + ] + } + }, + { + "type": "todo_list", + "checked": false, + "data": { + "delta": [ + { + "insert": "nest" + } + ] + } + } + ] + } + ] +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/toggle_list.json b/frontend/rust-lib/flowy-document2/tests/assets/json/toggle_list.json new file mode 100644 index 000000000000..89530afd15e9 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/toggle_list.json @@ -0,0 +1,25 @@ +{ + "type": "page", + "children": [ + { + "type": "toggle_list", + "data": { + "delta": [ + { "insert": "Click " }, + { "attributes": { "code": true }, "insert": "?" }, + { "insert": " at the bottom right for help and support." } + ] + }, + "children": [ + { + "type": "paragraph", + "data": { "delta": [{ "insert": "This is a paragraph" }] } + }, + { + "type": "toggle_list", + "data": { "delta": [{ "insert": "This is a toggle list" }] } + } + ] + } + ] +} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/text/bulleted_list.txt b/frontend/rust-lib/flowy-document2/tests/assets/text/bulleted_list.txt new file mode 100644 index 000000000000..59fc99d7fe1e --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/text/bulleted_list.txt @@ -0,0 +1,3 @@ +Highlight +You can also +nest diff --git a/frontend/rust-lib/flowy-document2/tests/assets/text/callout.txt b/frontend/rust-lib/flowy-document2/tests/assets/text/callout.txt new file mode 100644 index 000000000000..779f4f9f81f0 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/text/callout.txt @@ -0,0 +1,6 @@ +🥰 +Like AppFlowy? Follow us: +GitHub +Twitter: @appflowy +Newsletter + diff --git a/frontend/rust-lib/flowy-document2/tests/assets/text/code.txt b/frontend/rust-lib/flowy-document2/tests/assets/text/code.txt new file mode 100644 index 000000000000..9271ac6c895a --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/text/code.txt @@ -0,0 +1,5 @@ +// This is the main function. +fn main() { + // Print text to the console. + println!("Hello World!"); +} diff --git a/frontend/rust-lib/flowy-document2/tests/assets/text/divider.txt b/frontend/rust-lib/flowy-document2/tests/assets/text/divider.txt new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/text/divider.txt @@ -0,0 +1 @@ + diff --git a/frontend/rust-lib/flowy-document2/tests/assets/text/heading.txt b/frontend/rust-lib/flowy-document2/tests/assets/text/heading.txt new file mode 100644 index 000000000000..45fba2b33070 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/text/heading.txt @@ -0,0 +1,3 @@ +Heading1 +Heading2 +Heading3 diff --git a/frontend/rust-lib/flowy-document2/tests/assets/text/image.txt b/frontend/rust-lib/flowy-document2/tests/assets/text/image.txt new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/text/image.txt @@ -0,0 +1 @@ + diff --git a/frontend/rust-lib/flowy-document2/tests/assets/text/math_equation.txt b/frontend/rust-lib/flowy-document2/tests/assets/text/math_equation.txt new file mode 100644 index 000000000000..ba201486b534 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/text/math_equation.txt @@ -0,0 +1 @@ +E = MC^2 diff --git a/frontend/rust-lib/flowy-document2/tests/assets/text/numbered_list.txt b/frontend/rust-lib/flowy-document2/tests/assets/text/numbered_list.txt new file mode 100644 index 000000000000..59fc99d7fe1e --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/text/numbered_list.txt @@ -0,0 +1,3 @@ +Highlight +You can also +nest diff --git a/frontend/rust-lib/flowy-document2/tests/assets/text/paragraph.txt b/frontend/rust-lib/flowy-document2/tests/assets/text/paragraph.txt new file mode 100644 index 000000000000..893fbd1a7101 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/text/paragraph.txt @@ -0,0 +1,8 @@ + +Like AppFlowy? Follow us: +GitHub +Twitter: @appflowy +Newsletter + +Click ? at the bottom right for help and support. +Highlight any text, and use the editing menu to style your writing however you like.1+1=2 diff --git a/frontend/rust-lib/flowy-document2/tests/assets/text/plain_text.txt b/frontend/rust-lib/flowy-document2/tests/assets/text/plain_text.txt new file mode 100644 index 000000000000..71c07e6b788d --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/text/plain_text.txt @@ -0,0 +1,64 @@ +# The Notion Document + +# Heading-1 + +## Heading - 2 + +### Heading - 3 + +This is a paragraph + +paragraph’s child + +- This is a bulleted list - 1 + - This is a bulleted list - 1 - 1 +- This is a bulleted list - 2 + +This is a paragraph + +- [ ] This is a todo - 1 + - [ ] This is a paragraph - 1-1 +1. This is a numbered list -1 + +This is a paragraph + +- This is a toggle list + + This is a toggle child + + +> This is a quote +> +> +> This is a quote child +> + +This is a paragraph + +--- + +```jsx +// This is the main function. +fn main() { + // Print text to the console. + **println**!("Hello World!"); +} +``` + +This is a paragraph + +<aside> +💡 callout + +</aside> + +This is a paragraph font-color bg-color **bold** *italic underline ~~strike-through~~ `inline-code` $inline-formula$ [link](https://www.notion.so/The-Notion-Document-d4236da306b84f6199e4091705042d78?pvs=21)* + +$$ +|x| = \begin{cases}              +  x, &\quad x \geq 0 \\            + -x, &\quad x < 0              +\end{cases} +$$ + +End \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document2/tests/assets/text/quote.txt b/frontend/rust-lib/flowy-document2/tests/assets/text/quote.txt new file mode 100644 index 000000000000..e082baf25eee --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/text/quote.txt @@ -0,0 +1,2 @@ +This is a quote +This is a paragraph diff --git a/frontend/rust-lib/flowy-document2/tests/assets/text/todo_list.txt b/frontend/rust-lib/flowy-document2/tests/assets/text/todo_list.txt new file mode 100644 index 000000000000..59fc99d7fe1e --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/text/todo_list.txt @@ -0,0 +1,3 @@ +Highlight +You can also +nest diff --git a/frontend/rust-lib/flowy-document2/tests/assets/text/toggle_list.txt b/frontend/rust-lib/flowy-document2/tests/assets/text/toggle_list.txt new file mode 100644 index 000000000000..30369415c04b --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/assets/text/toggle_list.txt @@ -0,0 +1,3 @@ +Click ? at the bottom right for help and support. +This is a paragraph +This is a toggle list diff --git a/frontend/rust-lib/flowy-document2/tests/document/mod.rs b/frontend/rust-lib/flowy-document2/tests/document/mod.rs index e975a80c55a2..8d724a938beb 100644 --- a/frontend/rust-lib/flowy-document2/tests/document/mod.rs +++ b/frontend/rust-lib/flowy-document2/tests/document/mod.rs @@ -2,4 +2,4 @@ mod document_insert_test; mod document_redo_undo_test; mod document_test; mod event_handler_test; -mod util; +pub mod util; diff --git a/frontend/rust-lib/flowy-document2/tests/document/util.rs b/frontend/rust-lib/flowy-document2/tests/document/util.rs index 4ee4fcb109a3..6e29c6266e44 100644 --- a/frontend/rust-lib/flowy-document2/tests/document/util.rs +++ b/frontend/rust-lib/flowy-document2/tests/document/util.rs @@ -129,7 +129,7 @@ impl DocumentCloudService for LocalTestDocumentCloudServiceImpl { &self, _document_id: &str, _workspace_id: &str, - ) -> FutureResult<Vec<Vec<u8>>, Error> { + ) -> FutureResult<Vec<Vec<u8>>, FlowyError> { FutureResult::new(async move { Ok(vec![]) }) } diff --git a/frontend/rust-lib/flowy-document2/tests/parser/document_data_parser_test.rs b/frontend/rust-lib/flowy-document2/tests/parser/document_data_parser_test.rs new file mode 100644 index 000000000000..67a189603165 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/parser/document_data_parser_test.rs @@ -0,0 +1,105 @@ +use collab_document::blocks::DocumentData; +use flowy_document2::parser::document_data_parser::DocumentDataParser; +use flowy_document2::parser::json::parser::JsonToDocumentParser; +use flowy_document2::parser::parser_entities::{NestedBlock, Range, Selection}; +use std::sync::Arc; + +#[tokio::test] +async fn document_data_parse_json_test() { + let initial_json_str = include_str!("../assets/json/initial_document.json"); + let document_data = JsonToDocumentParser::json_str_to_document(initial_json_str) + .unwrap() + .into(); + let parser = DocumentDataParser::new(Arc::new(document_data), None); + let read_me_json = serde_json::from_str::<NestedBlock>(initial_json_str).unwrap(); + let json = parser.to_json().unwrap(); + assert_eq!(read_me_json, json); +} + +// range_1 is a range from the 2nd block to the 8th block +#[tokio::test] +async fn document_data_to_json_with_range_1_test() { + let initial_json_str = include_str!("../assets/json/initial_document.json"); + let document_data: DocumentData = JsonToDocumentParser::json_str_to_document(initial_json_str) + .unwrap() + .into(); + + let children_map = &document_data.meta.children_map; + let page_block_id = &document_data.page_id; + let blocks = &document_data.blocks; + let page_block = blocks.get(page_block_id).unwrap(); + let children = children_map.get(page_block.children.as_str()).unwrap(); + + let range = Range { + start: Selection { + block_id: children.get(1).unwrap().to_string(), + index: 4, + length: 15, + }, + end: Selection { + block_id: children.get(7).unwrap().to_string(), + index: 0, + length: 11, + }, + }; + let parser = DocumentDataParser::new(Arc::new(document_data), Some(range)); + let json = parser.to_json().unwrap(); + let part_1 = include_str!("../assets/json/range_1.json"); + let part_1_json = serde_json::from_str::<NestedBlock>(part_1).unwrap(); + assert_eq!(part_1_json, json); +} + +// range_2 is a range from the 4th block's first child to the 18th block's first child +#[tokio::test] +async fn document_data_to_json_with_range_2_test() { + let initial_json_str = include_str!("../assets/json/initial_document.json"); + let document_data: DocumentData = JsonToDocumentParser::json_str_to_document(initial_json_str) + .unwrap() + .into(); + + let children_map = &document_data.meta.children_map; + let page_block_id = &document_data.page_id; + let blocks = &document_data.blocks; + let page_block = blocks.get(page_block_id).unwrap(); + + let start_block_parent_id = children_map + .get(page_block.children.as_str()) + .unwrap() + .get(3) + .unwrap(); + let start_block_parent = blocks.get(start_block_parent_id).unwrap(); + let start_block_id = children_map + .get(start_block_parent.children.as_str()) + .unwrap() + .get(0) + .unwrap(); + + let start = Selection { + block_id: start_block_id.to_string(), + index: 6, + length: 27, + }; + + let end_block_parent_id = children_map + .get(page_block.children.as_str()) + .unwrap() + .get(17) + .unwrap(); + let end_block_parent = blocks.get(end_block_parent_id).unwrap(); + let end_block_children = children_map + .get(end_block_parent.children.as_str()) + .unwrap(); + let end_block_id = end_block_children.get(0).unwrap(); + let end = Selection { + block_id: end_block_id.to_string(), + index: 0, + length: 11, + }; + + let range = Range { start, end }; + let parser = DocumentDataParser::new(Arc::new(document_data), Some(range)); + let json = parser.to_json().unwrap(); + let part_2 = include_str!("../assets/json/range_2.json"); + let part_2_json = serde_json::from_str::<NestedBlock>(part_2).unwrap(); + assert_eq!(part_2_json, json); +} diff --git a/frontend/rust-lib/flowy-document2/tests/parser/html/mod.rs b/frontend/rust-lib/flowy-document2/tests/parser/html/mod.rs new file mode 100644 index 000000000000..945eb97109c6 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/parser/html/mod.rs @@ -0,0 +1 @@ +mod parser_test; diff --git a/frontend/rust-lib/flowy-document2/tests/parser/html/parser_test.rs b/frontend/rust-lib/flowy-document2/tests/parser/html/parser_test.rs new file mode 100644 index 000000000000..c70c38bea1cf --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/parser/html/parser_test.rs @@ -0,0 +1,45 @@ +use flowy_document2::parser::external::parser::ExternalDataToNestedJSONParser; +use flowy_document2::parser::parser_entities::{InputType, NestedBlock}; + +macro_rules! generate_test_cases { + ($($ty:ident),*) => { + [ + $( + ( + include_str!(concat!("../../assets/json/", stringify!($ty), ".json")), + include_str!(concat!("../../assets/html/", stringify!($ty), ".html")), + ) + ),* + ] + }; +} + +/// test convert data to json +/// - input html: <p>Hello</p><p> World!</p> +#[tokio::test] +async fn html_to_document_test() { + let test_cases = generate_test_cases!(notion, google_docs); + + for (json, html) in test_cases.iter() { + let parser = ExternalDataToNestedJSONParser::new(html.to_string(), InputType::Html); + let block = parser.to_nested_block(); + assert!(block.is_some()); + let block = block.unwrap(); + let expect_block = serde_json::from_str::<NestedBlock>(json).unwrap(); + assert_eq!(block, expect_block); + } +} + +/// test convert data to json +/// - input plain text: Hello World! +#[tokio::test] +async fn plain_text_to_document_test() { + let plain_text = include_str!("../../assets/text/plain_text.txt"); + let parser = ExternalDataToNestedJSONParser::new(plain_text.to_string(), InputType::PlainText); + let block = parser.to_nested_block(); + assert!(block.is_some()); + let block = block.unwrap(); + let expect_json = include_str!("../../assets/json/plain_text.json"); + let expect_block = serde_json::from_str::<NestedBlock>(expect_json).unwrap(); + assert_eq!(block, expect_block); +} diff --git a/frontend/rust-lib/flowy-document2/tests/parser/mod.rs b/frontend/rust-lib/flowy-document2/tests/parser/mod.rs index cff0e9089e51..71758c3fe477 100644 --- a/frontend/rust-lib/flowy-document2/tests/parser/mod.rs +++ b/frontend/rust-lib/flowy-document2/tests/parser/mod.rs @@ -1 +1,4 @@ +mod document_data_parser_test; +mod html; mod json; +mod parse_to_html_text; diff --git a/frontend/rust-lib/flowy-document2/tests/parser/parse_to_html_text/mod.rs b/frontend/rust-lib/flowy-document2/tests/parser/parse_to_html_text/mod.rs new file mode 100644 index 000000000000..60914e9678d0 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/parser/parse_to_html_text/mod.rs @@ -0,0 +1,2 @@ +mod test; +mod utils; diff --git a/frontend/rust-lib/flowy-document2/tests/parser/parse_to_html_text/test.rs b/frontend/rust-lib/flowy-document2/tests/parser/parse_to_html_text/test.rs new file mode 100644 index 000000000000..894d27e0450f --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/parser/parse_to_html_text/test.rs @@ -0,0 +1,37 @@ +use crate::parser::parse_to_html_text::utils::{assert_document_html_eq, assert_document_text_eq}; + +macro_rules! generate_test_cases { + ($($block_ty:ident),*) => { + [ + $( + ( + include_str!(concat!("../../assets/json/", stringify!($block_ty), ".json")), + include_str!(concat!("../../assets/html/", stringify!($block_ty), ".html")), + include_str!(concat!("../../assets/text/", stringify!($block_ty), ".txt")), + ) + ),* + ] + }; +} + +#[tokio::test] +async fn block_tests() { + let test_cases = generate_test_cases!( + heading, + callout, + paragraph, + divider, + image, + math_equation, + code, + bulleted_list, + numbered_list, + todo_list, + toggle_list, + quote + ); + for (json_data, expect_html, expect_text) in test_cases.iter() { + assert_document_html_eq(json_data, expect_html); + assert_document_text_eq(json_data, expect_text); + } +} diff --git a/frontend/rust-lib/flowy-document2/tests/parser/parse_to_html_text/utils.rs b/frontend/rust-lib/flowy-document2/tests/parser/parse_to_html_text/utils.rs new file mode 100644 index 000000000000..5484da9ede86 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/parser/parse_to_html_text/utils.rs @@ -0,0 +1,21 @@ +use flowy_document2::parser::document_data_parser::DocumentDataParser; +use flowy_document2::parser::json::parser::JsonToDocumentParser; +use std::sync::Arc; + +pub fn assert_document_html_eq(source: &str, expect: &str) { + let document_data = JsonToDocumentParser::json_str_to_document(source) + .unwrap() + .into(); + let parser = DocumentDataParser::new(Arc::new(document_data), None); + let html = parser.to_html(); + assert_eq!(expect, html); +} + +pub fn assert_document_text_eq(source: &str, expect: &str) { + let document_data = JsonToDocumentParser::json_str_to_document(source) + .unwrap() + .into(); + let parser = DocumentDataParser::new(Arc::new(document_data), None); + let text = parser.to_text(); + assert_eq!(expect, text); +} diff --git a/frontend/rust-lib/flowy-encrypt/Cargo.toml b/frontend/rust-lib/flowy-encrypt/Cargo.toml index 402d1a7b1aab..2041fccff15c 100644 --- a/frontend/rust-lib/flowy-encrypt/Cargo.toml +++ b/frontend/rust-lib/flowy-encrypt/Cargo.toml @@ -11,5 +11,5 @@ rand = "0.8" pbkdf2 = "0.12.2" hmac = "0.12.1" sha2 = "0.10.7" -anyhow = "1.0.72" +anyhow.workspace = true base64 = "0.21.2" \ No newline at end of file diff --git a/frontend/rust-lib/flowy-error/Cargo.toml b/frontend/rust-lib/flowy-error/Cargo.toml index ac48436bb789..6a99f0b155b9 100644 --- a/frontend/rust-lib/flowy-error/Cargo.toml +++ b/frontend/rust-lib/flowy-error/Cargo.toml @@ -7,17 +7,18 @@ edition = "2018" [dependencies] flowy-derive = { path = "../../../shared-lib/flowy-derive" } -protobuf = { version = "2.28.0" } -bytes = "1.4" -anyhow = "1.0" +protobuf.workspace = true +bytes.workspace = true +anyhow.workspace = true thiserror = "1.0" validator = "0.16.0" +tokio = { workspace = true, features = ["sync"]} fancy-regex = { version = "0.11.0" } lib-dispatch = { workspace = true, optional = true } -serde_json = { version = "1.0", optional = true } -serde_repr = { version = "0.1" } -serde = "1.0" +serde_json.workspace = true +serde_repr.workspace = true +serde.workspace = true reqwest = { version = "0.11.14", optional = true, features = [ "native-tls-vendored", ] } @@ -32,7 +33,7 @@ client-api = { version = "0.1.0", optional = true } [features] default = ["impl_from_appflowy_cloud", "impl_from_collab", "impl_from_reqwest", "impl_from_serde"] impl_from_dispatch_error = ["lib-dispatch"] -impl_from_serde = ["serde_json"] +impl_from_serde = [] impl_from_reqwest = ["reqwest"] impl_from_sqlite = ["flowy-sqlite", "r2d2"] impl_from_collab = ["collab-database", "collab-document", "impl_from_reqwest"] diff --git a/frontend/rust-lib/flowy-error/src/code.rs b/frontend/rust-lib/flowy-error/src/code.rs index 1675d6c5a792..5ff1f9741a6e 100644 --- a/frontend/rust-lib/flowy-error/src/code.rs +++ b/frontend/rust-lib/flowy-error/src/code.rs @@ -98,9 +98,6 @@ pub enum ErrorCode { #[error("user id is empty or whitespace")] UserIdInvalid = 30, - #[error("User not exist")] - UserNotExist = 31, - #[error("Text is too long")] TextTooLong = 32, @@ -256,6 +253,9 @@ pub enum ErrorCode { #[error("Internal server error")] InternalServerError = 84, + + #[error("Not support yet")] + NotSupportYet = 85, } impl ErrorCode { diff --git a/frontend/rust-lib/flowy-error/src/errors.rs b/frontend/rust-lib/flowy-error/src/errors.rs index d5a462058d18..10fd44e8eb5f 100644 --- a/frontend/rust-lib/flowy-error/src/errors.rs +++ b/frontend/rust-lib/flowy-error/src/errors.rs @@ -55,6 +55,10 @@ impl FlowyError { self.code == ErrorCode::RecordNotFound } + pub fn is_unauthorized(&self) -> bool { + self.code == ErrorCode::UserUnauthorized || self.code == ErrorCode::RecordNotFound + } + static_flowy_error!(internal, ErrorCode::Internal); static_flowy_error!(record_not_found, ErrorCode::RecordNotFound); static_flowy_error!(workspace_name, ErrorCode::WorkspaceNameInvalid); @@ -87,7 +91,6 @@ impl FlowyError { ); static_flowy_error!(name_empty, ErrorCode::UserNameIsEmpty); static_flowy_error!(user_id, ErrorCode::UserIdInvalid); - static_flowy_error!(user_not_exist, ErrorCode::UserNotExist); static_flowy_error!(text_too_long, ErrorCode::TextTooLong); static_flowy_error!(invalid_data, ErrorCode::InvalidParams); static_flowy_error!(out_of_bounds, ErrorCode::OutOfBounds); @@ -101,6 +104,7 @@ impl FlowyError { ); static_flowy_error!(collab_not_sync, ErrorCode::CollabDataNotSync); static_flowy_error!(server_error, ErrorCode::InternalServerError); + static_flowy_error!(not_support, ErrorCode::NotSupportYet); } impl std::convert::From<ErrorCode> for FlowyError { @@ -157,3 +161,9 @@ impl From<fancy_regex::Error> for FlowyError { FlowyError::internal().with_context(e) } } + +impl From<tokio::sync::oneshot::error::RecvError> for FlowyError { + fn from(e: tokio::sync::oneshot::error::RecvError) -> Self { + FlowyError::internal().with_context(e) + } +} diff --git a/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs b/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs index 97221c886fcc..65e3d073a016 100644 --- a/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs +++ b/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs @@ -1,26 +1,24 @@ -use client_api::error::AppError; +use client_api::error::{AppResponseError, ErrorCode as AppErrorCode}; use crate::{ErrorCode, FlowyError}; -impl From<AppError> for FlowyError { - fn from(error: AppError) -> Self { +impl From<AppResponseError> for FlowyError { + fn from(error: AppResponseError) -> Self { let code = match error.code { - client_api::error::ErrorCode::Ok => ErrorCode::Internal, - client_api::error::ErrorCode::Unhandled => ErrorCode::Internal, - client_api::error::ErrorCode::RecordNotFound => ErrorCode::RecordNotFound, - client_api::error::ErrorCode::RecordAlreadyExists => ErrorCode::RecordAlreadyExists, - client_api::error::ErrorCode::InvalidEmail => ErrorCode::EmailFormatInvalid, - client_api::error::ErrorCode::InvalidPassword => ErrorCode::PasswordFormatInvalid, - client_api::error::ErrorCode::OAuthError => ErrorCode::UserUnauthorized, - client_api::error::ErrorCode::MissingPayload => ErrorCode::MissingPayload, - client_api::error::ErrorCode::OpenError => ErrorCode::Internal, - client_api::error::ErrorCode::InvalidUrl => ErrorCode::InvalidURL, - client_api::error::ErrorCode::InvalidRequestParams => ErrorCode::InvalidParams, - client_api::error::ErrorCode::UrlMissingParameter => ErrorCode::InvalidParams, - client_api::error::ErrorCode::InvalidOAuthProvider => ErrorCode::InvalidAuthConfig, - client_api::error::ErrorCode::NotLoggedIn => ErrorCode::UserUnauthorized, - client_api::error::ErrorCode::NotEnoughPermissions => ErrorCode::NotEnoughPermissions, - client_api::error::ErrorCode::UserNameIsEmpty => ErrorCode::UserNameIsEmpty, + AppErrorCode::Ok => ErrorCode::Internal, + AppErrorCode::Unhandled => ErrorCode::Internal, + AppErrorCode::RecordNotFound => ErrorCode::RecordNotFound, + AppErrorCode::RecordAlreadyExists => ErrorCode::RecordAlreadyExists, + AppErrorCode::InvalidEmail => ErrorCode::EmailFormatInvalid, + AppErrorCode::InvalidPassword => ErrorCode::PasswordFormatInvalid, + AppErrorCode::OAuthError => ErrorCode::UserUnauthorized, + AppErrorCode::MissingPayload => ErrorCode::MissingPayload, + AppErrorCode::OpenError => ErrorCode::Internal, + AppErrorCode::InvalidUrl => ErrorCode::InvalidURL, + AppErrorCode::InvalidRequest => ErrorCode::InvalidParams, + AppErrorCode::InvalidOAuthProvider => ErrorCode::InvalidAuthConfig, + AppErrorCode::NotLoggedIn => ErrorCode::UserUnauthorized, + AppErrorCode::NotEnoughPermissions => ErrorCode::NotEnoughPermissions, _ => ErrorCode::Internal, }; diff --git a/frontend/rust-lib/flowy-folder-deps/Cargo.toml b/frontend/rust-lib/flowy-folder-deps/Cargo.toml index ec01d5bb05eb..26c6dfaf2717 100644 --- a/frontend/rust-lib/flowy-folder-deps/Cargo.toml +++ b/frontend/rust-lib/flowy-folder-deps/Cargo.toml @@ -9,5 +9,5 @@ edition = "2021" lib-infra = { path = "../../../shared-lib/lib-infra" } flowy-error = { workspace = true } collab-folder = { version = "0.1.0" } -uuid = { version = "1.3.3", features = ["v4"] } -anyhow = "1.0.71" \ No newline at end of file +uuid.workspace = true +anyhow.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-folder-deps/src/cloud.rs b/frontend/rust-lib/flowy-folder-deps/src/cloud.rs index 48985c3cd924..527051904354 100644 --- a/frontend/rust-lib/flowy-folder-deps/src/cloud.rs +++ b/frontend/rust-lib/flowy-folder-deps/src/cloud.rs @@ -1,14 +1,26 @@ pub use anyhow::Error; -pub use collab_folder::core::{Folder, FolderData, Workspace}; +pub use collab_folder::{Folder, FolderData, Workspace}; use uuid::Uuid; use lib_infra::future::FutureResult; /// [FolderCloudService] represents the cloud service for folder. pub trait FolderCloudService: Send + Sync + 'static { + /// Creates a new workspace for the user. + /// Returns error if the cloud service doesn't support multiple workspaces fn create_workspace(&self, uid: i64, name: &str) -> FutureResult<Workspace, Error>; - fn get_folder_data(&self, workspace_id: &str) -> FutureResult<Option<FolderData>, Error>; + fn open_workspace(&self, workspace_id: &str) -> FutureResult<(), Error>; + + /// Returns all workspaces of the user. + /// Returns vec![] if the cloud service doesn't support multiple workspaces + fn get_all_workspace(&self) -> FutureResult<Vec<WorkspaceRecord>, Error>; + + fn get_folder_data( + &self, + workspace_id: &str, + uid: &i64, + ) -> FutureResult<Option<FolderData>, Error>; fn get_folder_snapshots( &self, @@ -35,3 +47,10 @@ pub fn gen_workspace_id() -> Uuid { pub fn gen_view_id() -> Uuid { uuid::Uuid::new_v4() } + +#[derive(Debug)] +pub struct WorkspaceRecord { + pub id: String, + pub name: String, + pub created_at: i64, +} diff --git a/frontend/rust-lib/flowy-folder2/Cargo.toml b/frontend/rust-lib/flowy-folder2/Cargo.toml index 3b306db49408..0f9842582819 100644 --- a/frontend/rust-lib/flowy-folder2/Cargo.toml +++ b/frontend/rust-lib/flowy-folder2/Cargo.toml @@ -14,21 +14,21 @@ flowy-folder-deps = { workspace = true } flowy-derive = { path = "../../../shared-lib/flowy-derive" } flowy-notification = { workspace = true } -parking_lot = "0.12.1" +parking_lot.workspace = true unicode-segmentation = "1.10" -tracing = { version = "0.1", features = ["log"] } +tracing.workspace = true flowy-error = { path = "../flowy-error", features = ["impl_from_dispatch_error"]} lib-dispatch = { workspace = true } -bytes = { version = "1.5" } +bytes.workspace = true lib-infra = { path = "../../../shared-lib/lib-infra" } -tokio = { version = "1.26", features = ["full"] } +tokio = { workspace = true, features = ["full"] } nanoid = "0.4.0" lazy_static = "1.4.0" -chrono = { version = "0.4.31", default-features = false, features = ["clock"] } +chrono = { workspace = true, default-features = false, features = ["clock"] } strum_macros = "0.21" -protobuf = {version = "2.28.0"} -uuid = { version = "1.3.3", features = ["v4"] } -tokio-stream = { version = "0.1.14", features = ["sync"] } +protobuf.workspace = true +uuid.workspace = true +tokio-stream = { workspace = true, features = ["sync"] } [build-dependencies] flowy-codegen = { path = "../../../shared-lib/flowy-codegen"} diff --git a/frontend/rust-lib/flowy-folder2/src/entities/icon.rs b/frontend/rust-lib/flowy-folder2/src/entities/icon.rs index 08b8980209d7..2342b0224652 100644 --- a/frontend/rust-lib/flowy-folder2/src/entities/icon.rs +++ b/frontend/rust-lib/flowy-folder2/src/entities/icon.rs @@ -1,5 +1,5 @@ use crate::entities::parser::view::ViewIdentify; -use collab_folder::core::{IconType, ViewIcon}; +use collab_folder::{IconType, ViewIcon}; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; diff --git a/frontend/rust-lib/flowy-folder2/src/entities/trash.rs b/frontend/rust-lib/flowy-folder2/src/entities/trash.rs index b1158dbe079e..183324c8dbb6 100644 --- a/frontend/rust-lib/flowy-folder2/src/entities/trash.rs +++ b/frontend/rust-lib/flowy-folder2/src/entities/trash.rs @@ -1,4 +1,4 @@ -use collab_folder::core::TrashInfo; +use collab_folder::TrashInfo; use flowy_derive::ProtoBuf; #[derive(Eq, PartialEq, ProtoBuf, Default, Debug, Clone)] diff --git a/frontend/rust-lib/flowy-folder2/src/entities/view.rs b/frontend/rust-lib/flowy-folder2/src/entities/view.rs index f75f06980dec..f12048791e75 100644 --- a/frontend/rust-lib/flowy-folder2/src/entities/view.rs +++ b/frontend/rust-lib/flowy-folder2/src/entities/view.rs @@ -3,7 +3,7 @@ use std::convert::TryInto; use std::ops::{Deref, DerefMut}; use std::sync::Arc; -use collab_folder::core::{View, ViewLayout}; +use collab_folder::{View, ViewLayout}; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; diff --git a/frontend/rust-lib/flowy-folder2/src/entities/workspace.rs b/frontend/rust-lib/flowy-folder2/src/entities/workspace.rs index 0889d878bfba..6ce3328da695 100644 --- a/frontend/rust-lib/flowy-folder2/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-folder2/src/entities/workspace.rs @@ -1,7 +1,7 @@ use std::convert::TryInto; use collab::core::collab_state::SyncState; -use collab_folder::core::Workspace; +use collab_folder::Workspace; use flowy_derive::ProtoBuf; use flowy_error::ErrorCode; @@ -38,16 +38,16 @@ impl std::convert::From<(Workspace, Vec<ViewPB>)> for WorkspacePB { } } -impl std::convert::From<Workspace> for WorkspacePB { - fn from(workspace: Workspace) -> Self { - WorkspacePB { - id: workspace.id, - name: workspace.name, - views: Default::default(), - create_time: workspace.created_at, - } - } -} +// impl std::convert::From<Workspace> for WorkspacePB { +// fn from(workspace: Workspace) -> Self { +// WorkspacePB { +// id: workspace.id, +// name: workspace.name, +// views: Default::default(), +// create_time: workspace.created_at, +// } +// } +// } #[derive(PartialEq, Eq, Debug, Default, ProtoBuf)] pub struct RepeatedWorkspacePB { @@ -55,14 +55,9 @@ pub struct RepeatedWorkspacePB { pub items: Vec<WorkspacePB>, } -impl From<Vec<Workspace>> for RepeatedWorkspacePB { - fn from(workspaces: Vec<Workspace>) -> Self { - Self { - items: workspaces - .into_iter() - .map(|workspace| workspace.into()) - .collect::<Vec<WorkspacePB>>(), - } +impl From<Vec<WorkspacePB>> for RepeatedWorkspacePB { + fn from(workspaces: Vec<WorkspacePB>) -> Self { + Self { items: workspaces } } } @@ -98,22 +93,14 @@ impl TryInto<CreateWorkspaceParams> for CreateWorkspacePayloadPB { // Read all workspaces if the workspace_id is None #[derive(Clone, ProtoBuf, Default, Debug)] pub struct WorkspaceIdPB { - #[pb(index = 1, one_of)] - pub value: Option<String>, -} - -impl WorkspaceIdPB { - pub fn new(workspace_id: Option<String>) -> Self { - Self { - value: workspace_id, - } - } + #[pb(index = 1)] + pub value: String, } #[derive(Default, ProtoBuf, Debug, Clone)] pub struct WorkspaceSettingPB { #[pb(index = 1)] - pub workspace: WorkspacePB, + pub workspace_id: String, #[pb(index = 2, one_of)] pub latest_view: Option<ViewPB>, diff --git a/frontend/rust-lib/flowy-folder2/src/event_handler.rs b/frontend/rust-lib/flowy-folder2/src/event_handler.rs index fc586523a83a..4ab5398a1e75 100644 --- a/frontend/rust-lib/flowy-folder2/src/event_handler.rs +++ b/frontend/rust-lib/flowy-folder2/src/event_handler.rs @@ -24,7 +24,26 @@ pub(crate) async fn create_workspace_handler( let folder = upgrade_folder(folder)?; let params: CreateWorkspaceParams = data.into_inner().try_into()?; let workspace = folder.create_workspace(params).await?; - data_result_ok(workspace.into()) + let views = folder + .get_views_belong_to(&workspace.id) + .await? + .into_iter() + .map(view_pb_without_child_views) + .collect::<Vec<ViewPB>>(); + data_result_ok(WorkspacePB { + id: workspace.id, + name: workspace.name, + views, + create_time: workspace.created_at, + }) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn get_all_workspace_handler( + _data: AFPluginData<CreateWorkspacePayloadPB>, + _folder: AFPluginState<Weak<FolderManager>>, +) -> DataResult<RepeatedWorkspacePB, FlowyError> { + todo!() } #[tracing::instrument(level = "debug", skip(folder), err)] @@ -37,58 +56,22 @@ pub(crate) async fn get_workspace_views_handler( data_result_ok(repeated_view) } -#[tracing::instrument(level = "debug", skip(data, folder), err)] -pub(crate) async fn open_workspace_handler( - data: AFPluginData<WorkspaceIdPB>, - folder: AFPluginState<Weak<FolderManager>>, -) -> DataResult<WorkspacePB, FlowyError> { - let folder = upgrade_folder(folder)?; - let params: WorkspaceIdPB = data.into_inner(); - match params.value { - None => Err(FlowyError::workspace_id().with_context("workspace id should not be empty")), - Some(workspace_id) => { - if workspace_id.is_empty() { - Err(FlowyError::workspace_id().with_context("workspace id should not be empty")) - } else { - let workspace = folder.open_workspace(&workspace_id).await?; - let views = folder.get_workspace_views(&workspace_id).await?; - let workspace_pb: WorkspacePB = (workspace, views).into(); - data_result_ok(workspace_pb) - } - }, - } -} - -#[tracing::instrument(level = "debug", skip(data, folder), err)] -pub(crate) async fn read_workspaces_handler( - data: AFPluginData<WorkspaceIdPB>, +#[tracing::instrument(level = "debug", skip(folder), err)] +pub(crate) async fn read_current_workspace_setting_handler( folder: AFPluginState<Weak<FolderManager>>, -) -> DataResult<RepeatedWorkspacePB, FlowyError> { +) -> DataResult<WorkspaceSettingPB, FlowyError> { let folder = upgrade_folder(folder)?; - let params: WorkspaceIdPB = data.into_inner(); - let workspaces = match params.value { - None => folder.get_all_workspaces().await, - Some(workspace_id) => folder - .get_workspace(&workspace_id) - .await - .map(|workspace| vec![workspace]) - .unwrap_or_default(), - }; - - data_result_ok(workspaces.into()) + let setting = folder.get_workspace_setting_pb().await?; + data_result_ok(setting) } #[tracing::instrument(level = "debug", skip(folder), err)] -pub async fn get_current_workspace_setting_handler( +pub(crate) async fn read_current_workspace_handler( folder: AFPluginState<Weak<FolderManager>>, -) -> DataResult<WorkspaceSettingPB, FlowyError> { +) -> DataResult<WorkspacePB, FlowyError> { let folder = upgrade_folder(folder)?; - let workspace = folder.get_current_workspace().await?; - let latest_view: Option<ViewPB> = folder.get_current_view().await; - data_result_ok(WorkspaceSettingPB { - workspace, - latest_view, - }) + let workspace = folder.get_workspace_pb().await?; + data_result_ok(workspace) } pub(crate) async fn create_view_handler( @@ -125,7 +108,7 @@ pub(crate) async fn read_view_handler( ) -> DataResult<ViewPB, FlowyError> { let folder = upgrade_folder(folder)?; let view_id: ViewIdPB = data.into_inner(); - let view_pb = folder.get_view(&view_id.value).await?; + let view_pb = folder.get_view_pb(&view_id.value).await?; data_result_ok(view_pb) } @@ -236,22 +219,31 @@ pub(crate) async fn read_favorites_handler( folder: AFPluginState<Weak<FolderManager>>, ) -> DataResult<RepeatedViewPB, FlowyError> { let folder = upgrade_folder(folder)?; - let favorites = folder.get_all_favorites().await; + let favorite_items = folder.get_all_favorites().await; let mut views = vec![]; - for info in favorites { - let view = folder.get_view(&info.id).await; - match view { - Ok(view) => { - views.push(view); - }, - Err(err) => { - return Err(err); - }, + for item in favorite_items { + if let Ok(view) = folder.get_view_pb(&item.id).await { + views.push(view); } } + data_result_ok(RepeatedViewPB { items: views }) +} +#[tracing::instrument(level = "debug", skip(folder), err)] +pub(crate) async fn read_recent_views_handler( + folder: AFPluginState<Weak<FolderManager>>, +) -> DataResult<RepeatedViewPB, FlowyError> { + let folder = upgrade_folder(folder)?; + let recent_items = folder.get_all_recent_sections().await; + let mut views = vec![]; + for item in recent_items { + if let Ok(view) = folder.get_view_pb(&item.id).await { + views.push(view); + } + } data_result_ok(RepeatedViewPB { items: views }) } + #[tracing::instrument(level = "debug", skip(folder), err)] pub(crate) async fn read_trash_handler( folder: AFPluginState<Weak<FolderManager>>, @@ -319,10 +311,7 @@ pub(crate) async fn get_folder_snapshots_handler( folder: AFPluginState<Weak<FolderManager>>, ) -> DataResult<RepeatedFolderSnapshotPB, FlowyError> { let folder = upgrade_folder(folder)?; - if let Some(workspace_id) = &data.value { - let snapshots = folder.get_folder_snapshots(workspace_id, 10).await?; - data_result_ok(RepeatedFolderSnapshotPB { items: snapshots }) - } else { - data_result_ok(RepeatedFolderSnapshotPB { items: vec![] }) - } + let data = data.into_inner(); + let snapshots = folder.get_folder_snapshots(&data.value, 10).await?; + data_result_ok(RepeatedFolderSnapshotPB { items: snapshots }) } diff --git a/frontend/rust-lib/flowy-folder2/src/event_map.rs b/frontend/rust-lib/flowy-folder2/src/event_map.rs index 0b2aefce153b..f27ff376c5c1 100644 --- a/frontend/rust-lib/flowy-folder2/src/event_map.rs +++ b/frontend/rust-lib/flowy-folder2/src/event_map.rs @@ -12,12 +12,8 @@ pub fn init(folder: Weak<FolderManager>) -> AFPlugin { AFPlugin::new().name("Flowy-Folder").state(folder) // Workspace .event(FolderEvent::CreateWorkspace, create_workspace_handler) - .event( - FolderEvent::GetCurrentWorkspace, - get_current_workspace_setting_handler, - ) - .event(FolderEvent::ReadAllWorkspaces, read_workspaces_handler) - .event(FolderEvent::OpenWorkspace, open_workspace_handler) + .event(FolderEvent::GetCurrentWorkspaceSetting, read_current_workspace_setting_handler) + .event(FolderEvent::ReadCurrentWorkspace, read_current_workspace_handler) .event(FolderEvent::ReadWorkspaceViews, get_workspace_views_handler) // View .event(FolderEvent::CreateView, create_view_handler) @@ -40,6 +36,7 @@ pub fn init(folder: Weak<FolderManager>) -> AFPlugin { .event(FolderEvent::GetFolderSnapshots, get_folder_snapshots_handler) .event(FolderEvent::UpdateViewIcon, update_view_icon_handler) .event(FolderEvent::ReadFavorites, read_favorites_handler) + .event(FolderEvent::ReadRecentViews, read_recent_views_handler) .event(FolderEvent::ToggleFavorite, toggle_favorites_handler) } @@ -52,20 +49,16 @@ pub enum FolderEvent { /// Read the current opening workspace. Currently, we only support one workspace #[event(output = "WorkspaceSettingPB")] - GetCurrentWorkspace = 1, + GetCurrentWorkspaceSetting = 1, /// Return a list of workspaces that the current user can access. - #[event(input = "WorkspaceIdPB", output = "RepeatedWorkspacePB")] - ReadAllWorkspaces = 2, + #[event(output = "WorkspacePB")] + ReadCurrentWorkspace = 2, /// Delete the workspace #[event(input = "WorkspaceIdPB")] DeleteWorkspace = 3, - /// Open the workspace and mark it as the current workspace - #[event(input = "WorkspaceIdPB", output = "WorkspacePB")] - OpenWorkspace = 4, - /// Return a list of views of the current workspace. /// Only the first level of child views are included. #[event(input = "WorkspaceIdPB", output = "RepeatedViewPB")] @@ -153,4 +146,7 @@ pub enum FolderEvent { #[event(input = "UpdateViewIconPayloadPB")] UpdateViewIcon = 35, + + #[event(output = "RepeatedViewPB")] + ReadRecentViews = 36, } diff --git a/frontend/rust-lib/flowy-folder2/src/lib.rs b/frontend/rust-lib/flowy-folder2/src/lib.rs index 2e5df210ca88..b78899d21917 100644 --- a/frontend/rust-lib/flowy-folder2/src/lib.rs +++ b/frontend/rust-lib/flowy-folder2/src/lib.rs @@ -1,4 +1,4 @@ -pub use collab_folder::core::ViewLayout; +pub use collab_folder::ViewLayout; pub mod entities; pub mod event_handler; diff --git a/frontend/rust-lib/flowy-folder2/src/manager.rs b/frontend/rust-lib/flowy-folder2/src/manager.rs index b15ce1b2c56c..ea4c9e7a5fea 100644 --- a/frontend/rust-lib/flowy-folder2/src/manager.rs +++ b/frontend/rust-lib/flowy-folder2/src/manager.rs @@ -1,13 +1,14 @@ use std::collections::HashSet; +use std::fmt::{Display, Formatter}; use std::ops::Deref; use std::sync::{Arc, Weak}; use collab::core::collab::{CollabRawData, MutexCollab}; use collab::core::collab_state::SyncState; use collab_entity::CollabType; -use collab_folder::core::{ - FavoritesInfo, Folder, FolderData, FolderNotify, TrashChange, TrashChangeReceiver, TrashInfo, - View, ViewChange, ViewChangeReceiver, ViewLayout, ViewUpdate, Workspace, +use collab_folder::{ + Folder, FolderData, FolderNotify, Section, SectionItem, TrashChange, TrashChangeReceiver, + TrashInfo, UserId, View, ViewChange, ViewChangeReceiver, ViewLayout, ViewUpdate, Workspace, }; use parking_lot::{Mutex, RwLock}; use tokio_stream::wrappers::WatchStream; @@ -18,17 +19,17 @@ use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::{CollabPersistenceConfig, RocksCollabDB, YrsDocAction}; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_folder_deps::cloud::{gen_view_id, FolderCloudService}; +use lib_dispatch::prelude::af_spawn; use crate::entities::icon::UpdateViewIconParams; use crate::entities::{ view_pb_with_child_views, view_pb_without_child_views, ChildViewUpdatePB, CreateViewParams, CreateWorkspaceParams, DeletedViewPB, FolderSnapshotPB, FolderSnapshotStatePB, FolderSyncStatePB, - RepeatedTrashPB, RepeatedViewPB, RepeatedWorkspacePB, UpdateViewParams, UserFolderPB, ViewPB, - WorkspacePB, + RepeatedTrashPB, RepeatedViewPB, UpdateViewParams, UserFolderPB, ViewPB, WorkspacePB, + WorkspaceSettingPB, }; use crate::notification::{ - send_notification, send_workspace_notification, send_workspace_setting_notification, - FolderNotification, + send_notification, send_workspace_setting_notification, FolderNotification, }; use crate::share::ImportParams; use crate::user_default::DefaultFolderBuilder; @@ -73,6 +74,7 @@ impl FolderManager { Ok(manager) } + #[instrument(level = "debug", skip(self), err)] pub async fn get_current_workspace(&self) -> FlowyResult<WorkspacePB> { self.with_folder( || { @@ -91,19 +93,7 @@ impl FolderManager { }; match folder.get_current_workspace() { - None => { - // The current workspace should always exist. If not, try to find the first workspace. - // from the folder. Otherwise, return an error. - let mut workspaces = folder.workspaces.get_all_workspaces(); - if workspaces.is_empty() { - Err(FlowyError::record_not_found().with_context("Can not find the workspace")) - } else { - tracing::error!("Can't find the current workspace, use the first workspace"); - let workspace = workspaces.remove(0); - folder.set_current_workspace(&workspace.id); - workspace_pb_from_workspace(workspace, folder) - } - }, + None => Err(FlowyError::record_not_found().with_context("Can not find the workspace")), Some(workspace) => workspace_pb_from_workspace(workspace, folder), } }, @@ -117,9 +107,9 @@ impl FolderManager { .mutex_folder .lock() .as_ref() - .map(|folder| folder.get_current_workspace_id()); + .map(|folder| folder.get_workspace_id()); - if let Some(Some(workspace_id)) = workspace_id { + if let Some(workspace_id) = workspace_id { self.get_workspace_views(&workspace_id).await } else { tracing::warn!("Can't get current workspace views"); @@ -128,88 +118,105 @@ impl FolderManager { } pub async fn get_workspace_views(&self, workspace_id: &str) -> FlowyResult<Vec<ViewPB>> { - let views = self.with_folder(std::vec::Vec::new, |folder| { + let views = self.with_folder(Vec::new, |folder| { get_workspace_view_pbs(workspace_id, folder) }); Ok(views) } - /// Called immediately after the application launched fi the user already sign in/sign up. + /// Called immediately after the application launched if the user already sign in/sign up. #[tracing::instrument(level = "info", skip(self, initial_data), err)] pub async fn initialize( &self, uid: i64, workspace_id: &str, - initial_data: FolderInitializeDataSource, + initial_data: FolderInitDataSource, ) -> FlowyResult<()> { + // Update the workspace id + event!( + Level::INFO, + "Init current workspace: {} from: {}", + workspace_id, + initial_data + ); *self.workspace_id.write() = Some(workspace_id.to_string()); let workspace_id = workspace_id.to_string(); - if let Ok(collab_db) = self.user.collab_db(uid) { - let (view_tx, view_rx) = tokio::sync::broadcast::channel(100); - let (trash_tx, trash_rx) = tokio::sync::broadcast::channel(100); - let folder_notifier = FolderNotify { - view_change_tx: view_tx, - trash_change_tx: trash_tx, - }; - let folder = match initial_data { - FolderInitializeDataSource::LocalDisk { - create_if_not_exist, - } => { - let is_exist = is_exist_in_local_disk(&self.user, &workspace_id).unwrap_or(false); - if is_exist { - let collab = self - .collab_for_folder(uid, &workspace_id, collab_db, vec![]) - .await?; - Folder::open(collab, Some(folder_notifier)) - } else if create_if_not_exist { - let folder_data = - DefaultFolderBuilder::build(uid, workspace_id.to_string(), &self.operation_handlers) - .await; - let collab = self - .collab_for_folder(uid, &workspace_id, collab_db, vec![]) - .await?; - Folder::create(collab, Some(folder_notifier), Some(folder_data)) - } else { - return Err(FlowyError::new( - ErrorCode::RecordNotFound, - "Can't find any workspace data", - )); - } - }, - FolderInitializeDataSource::Cloud(raw_data) => { - if raw_data.is_empty() { - return Err(workspace_data_not_sync_error(uid, &workspace_id)); - } + // Get the collab db for the user with given user id. + let collab_db = self.user.collab_db(uid)?; + + let (view_tx, view_rx) = tokio::sync::broadcast::channel(100); + let (trash_tx, trash_rx) = tokio::sync::broadcast::channel(100); + let folder_notifier = FolderNotify { + view_change_tx: view_tx, + trash_change_tx: trash_tx, + }; + + let folder = match initial_data { + FolderInitDataSource::LocalDisk { + create_if_not_exist, + } => { + let is_exist = is_exist_in_local_disk(&self.user, &workspace_id).unwrap_or(false); + if is_exist { + event!(Level::INFO, "Restore folder from local disk"); let collab = self - .collab_for_folder(uid, &workspace_id, collab_db, raw_data) + .collab_for_folder(uid, &workspace_id, collab_db, vec![]) .await?; - Folder::open(collab, Some(folder_notifier)) - }, - FolderInitializeDataSource::FolderData(folder_data) => { + Folder::open(UserId::from(uid), collab, Some(folder_notifier))? + } else if create_if_not_exist { + event!(Level::INFO, "Create folder with default folder builder"); + let folder_data = + DefaultFolderBuilder::build(uid, workspace_id.to_string(), &self.operation_handlers) + .await; let collab = self .collab_for_folder(uid, &workspace_id, collab_db, vec![]) .await?; - Folder::create(collab, Some(folder_notifier), Some(folder_data)) - }, - }; - - tracing::debug!("Current workspace_id: {}", workspace_id); - let folder_state_rx = folder.subscribe_sync_state(); - *self.mutex_folder.lock() = Some(folder); + Folder::create( + UserId::from(uid), + collab, + Some(folder_notifier), + folder_data, + ) + } else { + return Err(FlowyError::new( + ErrorCode::RecordNotFound, + "Can't find any workspace data", + )); + } + }, + FolderInitDataSource::Cloud(raw_data) => { + event!(Level::INFO, "Restore folder from cloud service"); + if raw_data.is_empty() { + return Err(workspace_data_not_sync_error(uid, &workspace_id)); + } + let collab = self + .collab_for_folder(uid, &workspace_id, collab_db, raw_data) + .await?; + Folder::open(UserId::from(uid), collab, Some(folder_notifier))? + }, + FolderInitDataSource::FolderData(folder_data) => { + event!(Level::INFO, "Restore folder with passed-in folder data"); + let collab = self + .collab_for_folder(uid, &workspace_id, collab_db, vec![]) + .await?; + Folder::create( + UserId::from(uid), + collab, + Some(folder_notifier), + folder_data, + ) + }, + }; - let weak_mutex_folder = Arc::downgrade(&self.mutex_folder); - subscribe_folder_sync_state_changed( - workspace_id.clone(), - folder_state_rx, - &weak_mutex_folder, - ); - subscribe_folder_snapshot_state_changed(workspace_id, &weak_mutex_folder); - subscribe_folder_trash_changed(trash_rx, &weak_mutex_folder); - subscribe_folder_view_changed(view_rx, &weak_mutex_folder); - } + let folder_state_rx = folder.subscribe_sync_state(); + *self.mutex_folder.lock() = Some(folder); + let weak_mutex_folder = Arc::downgrade(&self.mutex_folder); + subscribe_folder_sync_state_changed(workspace_id.clone(), folder_state_rx, &weak_mutex_folder); + subscribe_folder_snapshot_state_changed(workspace_id, &weak_mutex_folder); + subscribe_folder_trash_changed(trash_rx, &weak_mutex_folder); + subscribe_folder_view_changed(view_rx, &weak_mutex_folder); Ok(()) } @@ -236,7 +243,7 @@ impl FolderManager { /// Initialize the folder with the given workspace id. /// Fetch the folder updates from the cloud service and initialize the folder. - #[tracing::instrument(level = "debug", skip(self, user_id), err)] + #[tracing::instrument(skip(self, user_id), err)] pub async fn initialize_with_workspace_id( &self, user_id: i64, @@ -247,7 +254,8 @@ impl FolderManager { .get_folder_updates(workspace_id, user_id) .await?; - info!( + event!( + Level::INFO, "Get folder updates via {}, number of updates: {}", self.cloud_service.service_name(), folder_updates.len() @@ -257,7 +265,7 @@ impl FolderManager { .initialize( user_id, workspace_id, - FolderInitializeDataSource::Cloud(folder_updates), + FolderInitDataSource::Cloud(folder_updates), ) .await?; Ok(()) @@ -265,18 +273,13 @@ impl FolderManager { /// Initialize the folder for the new user. /// Using the [DefaultFolderBuilder] to create the default workspace for the new user. - #[instrument( - name = "folder_initialize_with_new_user", - level = "debug", - skip_all, - err - )] + #[instrument(level = "info", skip_all, err)] pub async fn initialize_with_new_user( &self, user_id: i64, _token: &str, is_new: bool, - data_source: FolderInitializeDataSource, + data_source: FolderInitDataSource, workspace_id: &str, ) -> FlowyResult<()> { // Create the default workspace if the user is new @@ -303,7 +306,7 @@ impl FolderManager { .initialize( user_id, workspace_id, - FolderInitializeDataSource::Cloud(folder_updates), + FolderInitDataSource::Cloud(folder_updates), ) .await?; }, @@ -324,49 +327,62 @@ impl FolderManager { pub async fn clear(&self, _user_id: i64) {} #[tracing::instrument(level = "info", skip_all, err)] - pub async fn create_workspace(&self, params: CreateWorkspaceParams) -> FlowyResult<Workspace> { - let workspace = self - .cloud_service - .create_workspace(self.user.user_id()?, ¶ms.name) - .await?; - - self.with_folder( - || (), - |folder| { - folder.workspaces.create_workspace(workspace.clone()); - folder.set_current_workspace(&workspace.id); - }, - ); - - let repeated_workspace = RepeatedWorkspacePB { - items: vec![workspace.clone().into()], - }; - send_workspace_notification(FolderNotification::DidCreateWorkspace, repeated_workspace); - Ok(workspace) + pub async fn create_workspace(&self, _params: CreateWorkspaceParams) -> FlowyResult<Workspace> { + Err(FlowyError::not_support()) } #[tracing::instrument(level = "info", skip_all, err)] - pub async fn open_workspace(&self, workspace_id: &str) -> FlowyResult<Workspace> { + pub async fn open_workspace(&self, _workspace_id: &str) -> FlowyResult<Workspace> { self.with_folder( || Err(FlowyError::internal()), |folder| { - let workspace = folder - .workspaces - .get_workspace(workspace_id) - .ok_or_else(|| { - FlowyError::record_not_found().with_context("Can't open not existing workspace") - })?; - folder.set_current_workspace(&workspace.id); + let workspace = folder.get_current_workspace().ok_or_else(|| { + FlowyError::record_not_found().with_context("Can't open not existing workspace") + })?; Ok::<Workspace, FlowyError>(workspace) }, ) } - pub async fn get_workspace(&self, workspace_id: &str) -> Option<Workspace> { - self.with_folder( - || None, - |folder| folder.workspaces.get_workspace(workspace_id), - ) + pub async fn get_workspace(&self, _workspace_id: &str) -> Option<Workspace> { + self.with_folder(|| None, |folder| folder.get_current_workspace()) + } + + pub async fn get_workspace_setting_pb(&self) -> FlowyResult<WorkspaceSettingPB> { + let workspace_id = self.get_current_workspace_id().await?; + let latest_view = self.get_current_view().await; + Ok(WorkspaceSettingPB { + workspace_id, + latest_view, + }) + } + + pub async fn get_workspace_pb(&self) -> FlowyResult<WorkspacePB> { + let workspace_pb = { + let guard = self.mutex_folder.lock(); + let folder = guard + .as_ref() + .ok_or(FlowyError::internal().with_context("folder is not initialized"))?; + let workspace = folder.get_current_workspace().ok_or( + FlowyError::record_not_found().with_context("Can't find the current workspace id "), + )?; + + let views = folder + .views + .get_views_belong_to(&workspace.id) + .into_iter() + .map(view_pb_without_child_views) + .collect::<Vec<ViewPB>>(); + + WorkspacePB { + id: workspace.id, + name: workspace.name, + views, + create_time: workspace.created_at, + } + }; + + Ok(workspace_pb) } async fn get_current_workspace_id(&self) -> FlowyResult<String> { @@ -374,7 +390,7 @@ impl FolderManager { .mutex_folder .lock() .as_ref() - .and_then(|folder| folder.get_current_workspace_id()) + .map(|folder| folder.get_workspace_id()) .ok_or(FlowyError::internal().with_context("Unexpected empty workspace id")) } @@ -398,8 +414,12 @@ impl FolderManager { } pub async fn get_all_workspaces(&self) -> Vec<Workspace> { - self.with_folder(std::vec::Vec::new, |folder| { - folder.workspaces.get_all_workspaces() + self.with_folder(Vec::new, |folder| { + let mut workspaces = vec![]; + if let Some(workspace) = folder.get_current_workspace() { + workspaces.push(workspace); + } + workspaces }) } @@ -430,7 +450,7 @@ impl FolderManager { } let index = params.index; - let view = create_view(params, view_layout); + let view = create_view(self.user.user_id()?, params, view_layout); self.with_folder( || (), |folder| { @@ -454,7 +474,7 @@ impl FolderManager { handler .create_built_in_view(user_id, ¶ms.view_id, ¶ms.name, view_layout.clone()) .await?; - let view = create_view(params, view_layout); + let view = create_view(self.user.user_id()?, params, view_layout); self.with_folder( || (), |folder| { @@ -477,7 +497,7 @@ impl FolderManager { /// The child views of the view will only access the first. So if you want to get the child view's /// child view, you need to call this method again. #[tracing::instrument(level = "debug", skip(self, view_id), err)] - pub async fn get_view(&self, view_id: &str) -> FlowyResult<ViewPB> { + pub async fn get_view_pb(&self, view_id: &str) -> FlowyResult<ViewPB> { let view_id = view_id.to_string(); let folder = self.mutex_folder.lock(); let folder = folder.as_ref().ok_or_else(folder_not_init_error)?; @@ -585,7 +605,7 @@ impl FolderManager { new_parent_id: String, prev_view_id: Option<String>, ) -> FlowyResult<()> { - let view = self.get_view(&view_id).await?; + let view = self.get_view_pb(&view_id).await?; let old_parent_id = view.parent_view_id; self.with_folder( || (), @@ -619,7 +639,7 @@ impl FolderManager { .collect::<Vec<_>>() } else { self - .get_view(&parent_view_id) + .get_view_pb(&parent_view_id) .await? .child_views .into_iter() @@ -652,7 +672,7 @@ impl FolderManager { /// Return a list of views that belong to the given parent view id. #[tracing::instrument(level = "debug", skip(self, parent_view_id), err)] pub async fn get_views_belong_to(&self, parent_view_id: &str) -> FlowyResult<Vec<Arc<View>>> { - let views = self.with_folder(std::vec::Vec::new, |folder| { + let views = self.with_folder(Vec::new, |folder| { folder.views.get_views_belong_to(parent_view_id) }); Ok(views) @@ -721,22 +741,23 @@ impl FolderManager { #[tracing::instrument(level = "trace", skip(self), err)] pub(crate) async fn set_current_view(&self, view_id: &str) -> Result<(), FlowyError> { - let folder = self.mutex_folder.lock(); - let folder = folder.as_ref().ok_or_else(folder_not_init_error)?; - folder.set_current_view(view_id); + let workspace_id = self.with_folder( + || Err(FlowyError::record_not_found()), + |folder| { + folder.set_current_view(view_id); + folder.add_recent_view_ids(vec![view_id.to_string()]); + Ok(folder.get_workspace_id()) + }, + )?; - let workspace = folder.get_current_workspace(); - let view = folder - .get_current_view() - .and_then(|view_id| folder.views.get_view(&view_id)); - send_workspace_setting_notification(workspace, view); + send_workspace_setting_notification(workspace_id, self.get_current_view().await); Ok(()) } #[tracing::instrument(level = "trace", skip(self))] pub(crate) async fn get_current_view(&self) -> Option<ViewPB> { let view_id = self.with_folder(|| None, |folder| folder.get_current_view())?; - self.get_view(&view_id).await.ok() + self.get_view_pb(&view_id).await.ok() } /// Toggles the favorite status of a view identified by `view_id`If the view is not a favorite, it will be added to the favorites list; otherwise, it will be removed from the list. @@ -760,7 +781,7 @@ impl FolderManager { // Used by toggle_favorites to send notification to frontend, after the favorite status of view has been changed.It sends two distinct notifications: one to correctly update the concerned view's is_favorite status, and another to update the list of favorites that is to be displayed. async fn send_toggle_favorite_notification(&self, view_id: &str) { - if let Ok(view) = self.get_view(view_id).await { + if let Ok(view) = self.get_view_pb(view_id).await { let notification_type = if view.is_favorite { FolderNotification::DidFavoriteView } else { @@ -779,23 +800,18 @@ impl FolderManager { } #[tracing::instrument(level = "trace", skip(self))] - pub(crate) async fn get_all_favorites(&self) -> Vec<FavoritesInfo> { - self.with_folder(std::vec::Vec::new, |folder| { - let trash_ids = folder - .get_all_trash() - .into_iter() - .map(|trash| trash.id) - .collect::<Vec<String>>(); + pub(crate) async fn get_all_favorites(&self) -> Vec<SectionItem> { + self.get_sections(Section::Favorite) + } - let mut views = folder.get_all_favorites(); - views.retain(|view| !trash_ids.contains(&view.id)); - views - }) + #[tracing::instrument(level = "trace", skip(self))] + pub(crate) async fn get_all_recent_sections(&self) -> Vec<SectionItem> { + self.get_sections(Section::Recent) } #[tracing::instrument(level = "trace", skip(self))] pub(crate) async fn get_all_trash(&self) -> Vec<TrashInfo> { - self.with_folder(std::vec::Vec::new, |folder| folder.get_all_trash()) + self.with_folder(Vec::new, |folder| folder.get_all_trash()) } #[tracing::instrument(level = "trace", skip(self))] @@ -824,7 +840,7 @@ impl FolderManager { /// Delete all the trash permanently. #[tracing::instrument(level = "trace", skip(self))] pub(crate) async fn delete_all_trash(&self) { - let deleted_trash = self.with_folder(std::vec::Vec::new, |folder| folder.get_all_trash()); + let deleted_trash = self.with_folder(Vec::new, |folder| folder.get_all_trash()); for trash in deleted_trash { let _ = self.delete_trash(&trash.id).await; } @@ -895,7 +911,7 @@ impl FolderManager { index: None, }; - let view = create_view(params, import_data.view_layout); + let view = create_view(self.user.user_id()?, params, import_data.view_layout); self.with_folder( || (), |folder| { @@ -927,7 +943,7 @@ impl FolderManager { } } - if let Ok(view_pb) = self.get_view(view_id).await { + if let Ok(view_pb) = self.get_view_pb(view_id).await { send_notification(&view_pb.id, FolderNotification::DidUpdateView) .payload(view_pb) .send(); @@ -1019,6 +1035,26 @@ impl FolderManager { pub fn get_cloud_service(&self) -> &Arc<dyn FolderCloudService> { &self.cloud_service } + + fn get_sections(&self, section_type: Section) -> Vec<SectionItem> { + self.with_folder(Vec::new, |folder| { + let trash_ids = folder + .get_all_trash() + .into_iter() + .map(|trash| trash.id) + .collect::<Vec<String>>(); + + let mut views = match section_type { + Section::Favorite => folder.get_all_favorites(), + Section::Recent => folder.get_all_recent_sections(), + _ => vec![], + }; + + // filter the views that are in the trash + views.retain(|view| !trash_ids.contains(&view.id)); + views + }) + } } /// Listen on the [ViewChange] after create/delete/update events happened @@ -1027,7 +1063,7 @@ fn subscribe_folder_view_changed( weak_mutex_folder: &Weak<MutexFolder>, ) { let weak_mutex_folder = weak_mutex_folder.clone(); - tokio::spawn(async move { + af_spawn(async move { while let Ok(value) = rx.recv().await { if let Some(folder) = weak_mutex_folder.upgrade() { tracing::trace!("Did receive view change: {:?}", value); @@ -1065,7 +1101,7 @@ fn subscribe_folder_snapshot_state_changed( weak_mutex_folder: &Weak<MutexFolder>, ) { let weak_mutex_folder = weak_mutex_folder.clone(); - tokio::spawn(async move { + af_spawn(async move { if let Some(mutex_folder) = weak_mutex_folder.upgrade() { let stream = mutex_folder .lock() @@ -1093,7 +1129,7 @@ fn subscribe_folder_sync_state_changed( mut folder_sync_state_rx: WatchStream<SyncState>, _weak_mutex_folder: &Weak<MutexFolder>, ) { - tokio::spawn(async move { + af_spawn(async move { while let Some(state) = folder_sync_state_rx.next().await { send_notification(&workspace_id, FolderNotification::DidUpdateFolderSyncUpdate) .payload(FolderSyncStatePB::from(state)) @@ -1108,7 +1144,7 @@ fn subscribe_folder_trash_changed( weak_mutex_folder: &Weak<MutexFolder>, ) { let weak_mutex_folder = weak_mutex_folder.clone(); - tokio::spawn(async move { + af_spawn(async move { while let Ok(value) = rx.recv().await { if let Some(folder) = weak_mutex_folder.upgrade() { let mut unique_ids = HashSet::new(); @@ -1178,7 +1214,7 @@ fn notify_parent_view_did_change<T: AsRef<str>>( ) -> Option<()> { let folder = folder.lock(); let folder = folder.as_ref()?; - let workspace_id = folder.get_current_workspace_id()?; + let workspace_id = folder.get_workspace_id(); let trash_ids = folder .get_all_trash() .into_iter() @@ -1258,7 +1294,8 @@ impl Deref for MutexFolder { unsafe impl Sync for MutexFolder {} unsafe impl Send for MutexFolder {} -pub enum FolderInitializeDataSource { +#[allow(clippy::large_enum_variant)] +pub enum FolderInitDataSource { /// It means using the data stored on local disk to initialize the folder LocalDisk { create_if_not_exist: bool }, /// If there is no data stored on local disk, we will use the data from the server to initialize the folder @@ -1267,6 +1304,16 @@ pub enum FolderInitializeDataSource { FolderData(FolderData), } +impl Display for FolderInitDataSource { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + FolderInitDataSource::LocalDisk { .. } => f.write_fmt(format_args!("LocalDisk")), + FolderInitDataSource::Cloud(_) => f.write_fmt(format_args!("Cloud")), + FolderInitDataSource::FolderData(_) => f.write_fmt(format_args!("Custom FolderData")), + } + } +} + fn is_exist_in_local_disk(user: &Arc<dyn FolderUser>, doc_id: &str) -> FlowyResult<bool> { let uid = user.user_id()?; if let Some(collab_db) = user.collab_db(uid)?.upgrade() { diff --git a/frontend/rust-lib/flowy-folder2/src/notification.rs b/frontend/rust-lib/flowy-folder2/src/notification.rs index 2d41567b5769..cff47d917f94 100644 --- a/frontend/rust-lib/flowy-folder2/src/notification.rs +++ b/frontend/rust-lib/flowy-folder2/src/notification.rs @@ -1,12 +1,8 @@ -use std::sync::Arc; - -use collab_folder::core::{View, Workspace}; - use flowy_derive::ProtoBuf_Enum; use flowy_notification::NotificationBuilder; use lib_dispatch::prelude::ToBytes; -use crate::entities::{view_pb_without_child_views, WorkspacePB, WorkspaceSettingPB}; +use crate::entities::{ViewPB, WorkspaceSettingPB}; const FOLDER_OBSERVABLE_SOURCE: &str = "Workspace"; @@ -82,13 +78,11 @@ pub(crate) fn send_workspace_notification<T: ToBytes>(ty: FolderNotification, pa } pub(crate) fn send_workspace_setting_notification( - current_workspace: Option<Workspace>, - current_view: Option<Arc<View>>, + workspace_id: String, + latest_view: Option<ViewPB>, ) -> Option<()> { - let workspace: WorkspacePB = current_workspace?.into(); - let latest_view = current_view.map(view_pb_without_child_views); let setting = WorkspaceSettingPB { - workspace, + workspace_id, latest_view, }; send_workspace_notification(FolderNotification::DidUpdateWorkspaceSetting, setting); diff --git a/frontend/rust-lib/flowy-folder2/src/share/import.rs b/frontend/rust-lib/flowy-folder2/src/share/import.rs index af11f32006a7..531461a232c1 100644 --- a/frontend/rust-lib/flowy-folder2/src/share/import.rs +++ b/frontend/rust-lib/flowy-folder2/src/share/import.rs @@ -1,4 +1,4 @@ -use collab_folder::core::ViewLayout; +use collab_folder::ViewLayout; #[derive(Clone, Debug)] pub enum ImportType { diff --git a/frontend/rust-lib/flowy-folder2/src/user_default.rs b/frontend/rust-lib/flowy-folder2/src/user_default.rs index e5f02cd2bcb3..910273a60b60 100644 --- a/frontend/rust-lib/flowy-folder2/src/user_default.rs +++ b/frontend/rust-lib/flowy-folder2/src/user_default.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use collab_folder::core::{FolderData, RepeatedViewIdentifier, ViewIdentifier, Workspace}; +use collab_folder::{FolderData, RepeatedViewIdentifier, ViewIdentifier, Workspace}; use tokio::sync::RwLock; use lib_infra::util::timestamp; @@ -17,8 +17,10 @@ impl DefaultFolderBuilder { workspace_id: String, handlers: &FolderOperationHandlers, ) -> FolderData { - let workspace_view_builder = - Arc::new(RwLock::new(WorkspaceViewBuilder::new(workspace_id.clone()))); + let workspace_view_builder = Arc::new(RwLock::new(WorkspaceViewBuilder::new( + workspace_id.clone(), + uid, + ))); for handler in handlers.values() { let _ = handler .create_workspace_view(uid, workspace_view_builder.clone()) @@ -41,13 +43,17 @@ impl DefaultFolderBuilder { name: "Workspace".to_string(), child_views: RepeatedViewIdentifier::new(first_level_views), created_at: timestamp(), + created_by: Some(uid), + last_edited_time: timestamp(), + last_edited_by: Some(uid), }; FolderData { - current_workspace_id: workspace.id.clone(), + workspace, current_view: first_view.id, - workspaces: vec![workspace], views: FlattedViews::flatten_views(views), + favorites: Default::default(), + recent: Default::default(), } } } diff --git a/frontend/rust-lib/flowy-folder2/src/view_operation.rs b/frontend/rust-lib/flowy-folder2/src/view_operation.rs index be1d935cf367..7aae7742d568 100644 --- a/frontend/rust-lib/flowy-folder2/src/view_operation.rs +++ b/frontend/rust-lib/flowy-folder2/src/view_operation.rs @@ -3,8 +3,8 @@ use std::future::Future; use std::sync::Arc; use bytes::Bytes; -pub use collab_folder::core::View; -use collab_folder::core::{RepeatedViewIdentifier, ViewIcon, ViewIdentifier, ViewLayout}; +pub use collab_folder::View; +use collab_folder::{RepeatedViewIdentifier, ViewIcon, ViewIdentifier, ViewLayout}; use tokio::sync::RwLock; use flowy_error::FlowyError; @@ -20,13 +20,15 @@ pub type ViewData = Bytes; /// A builder for creating a view for a workspace. /// The views created by this builder will be the first level views of the workspace. pub struct WorkspaceViewBuilder { + pub uid: i64, pub workspace_id: String, pub views: Vec<ParentChildViews>, } impl WorkspaceViewBuilder { - pub fn new(workspace_id: String) -> Self { + pub fn new(workspace_id: String, uid: i64) -> Self { Self { + uid, workspace_id, views: vec![], } @@ -37,7 +39,7 @@ impl WorkspaceViewBuilder { F: Fn(ViewBuilder) -> O, O: Future<Output = ParentChildViews>, { - let builder = ViewBuilder::new(self.workspace_id.clone()); + let builder = ViewBuilder::new(self.uid, self.workspace_id.clone()); self.views.push(view_builder(builder).await); } @@ -49,6 +51,7 @@ impl WorkspaceViewBuilder { /// A builder for creating a view. /// The default layout of the view is [ViewLayout::Document] pub struct ViewBuilder { + uid: i64, parent_view_id: String, view_id: String, name: String, @@ -60,8 +63,9 @@ pub struct ViewBuilder { } impl ViewBuilder { - pub fn new(parent_view_id: String) -> Self { + pub fn new(uid: i64, parent_view_id: String) -> Self { Self { + uid, parent_view_id, view_id: gen_view_id().to_string(), name: Default::default(), @@ -92,6 +96,14 @@ impl ViewBuilder { self } + pub fn with_icon(mut self, icon: &str) -> Self { + self.icon = Some(ViewIcon { + ty: collab_folder::IconType::Emoji, + value: icon.to_string(), + }); + self + } + /// Create a child view for the current view. /// The view created by this builder will be the next level view of the current view. pub async fn with_child_view_builder<F, O>(mut self, child_view_builder: F) -> Self @@ -99,7 +111,7 @@ impl ViewBuilder { F: Fn(ViewBuilder) -> O, O: Future<Output = ParentChildViews>, { - let builder = ViewBuilder::new(self.view_id.clone()); + let builder = ViewBuilder::new(self.uid, self.view_id.clone()); self.child_views.push(child_view_builder(builder).await); self } @@ -114,6 +126,8 @@ impl ViewBuilder { is_favorite: self.is_favorite, layout: self.layout, icon: self.icon, + created_by: Some(self.uid), + last_edited_time: 0, children: RepeatedViewIdentifier::new( self .child_views @@ -123,6 +137,7 @@ impl ViewBuilder { }) .collect(), ), + last_edited_by: Some(self.uid), }; ParentChildViews { parent_view: view, @@ -246,7 +261,7 @@ impl From<ViewLayoutPB> for ViewLayout { } } -pub(crate) fn create_view(params: CreateViewParams, layout: ViewLayout) -> View { +pub(crate) fn create_view(uid: i64, params: CreateViewParams, layout: ViewLayout) -> View { let time = timestamp(); View { id: params.view_id, @@ -258,6 +273,9 @@ pub(crate) fn create_view(params: CreateViewParams, layout: ViewLayout) -> View is_favorite: false, layout, icon: None, + created_by: Some(uid), + last_edited_time: 0, + last_edited_by: Some(uid), } } @@ -268,7 +286,7 @@ mod tests { #[tokio::test] async fn create_first_level_views_test() { let workspace_id = "w1".to_string(); - let mut builder = WorkspaceViewBuilder::new(workspace_id); + let mut builder = WorkspaceViewBuilder::new(workspace_id, 1); builder .with_view_builder(|view_builder| async { view_builder.with_name("1").build() }) .await; @@ -288,7 +306,7 @@ mod tests { #[tokio::test] async fn create_view_with_child_views_test() { let workspace_id = "w1".to_string(); - let mut builder = WorkspaceViewBuilder::new(workspace_id); + let mut builder = WorkspaceViewBuilder::new(workspace_id, 1); builder .with_view_builder(|view_builder| async { view_builder @@ -331,7 +349,7 @@ mod tests { #[tokio::test] async fn create_three_level_view_test() { let workspace_id = "w1".to_string(); - let mut builder = WorkspaceViewBuilder::new(workspace_id); + let mut builder = WorkspaceViewBuilder::new(workspace_id, 1); builder .with_view_builder(|view_builder| async { view_builder diff --git a/frontend/rust-lib/flowy-notification/Cargo.toml b/frontend/rust-lib/flowy-notification/Cargo.toml index 64dab43b4071..ef8886cd28c0 100644 --- a/frontend/rust-lib/flowy-notification/Cargo.toml +++ b/frontend/rust-lib/flowy-notification/Cargo.toml @@ -7,10 +7,10 @@ edition = "2018" [dependencies] lazy_static = { version = "1.4.0" } -protobuf = { version = "2.28.0" } -tracing = { version = "0.1", features = ["log"] } -bytes = { version = "1.5" } -serde = "1.0" +protobuf.workspace = true +tracing.workspace = true +bytes.workspace = true +serde.workspace = true flowy-derive = { path = "../../../shared-lib/flowy-derive" } lib-dispatch = { workspace = true } diff --git a/frontend/rust-lib/flowy-server-config/Cargo.toml b/frontend/rust-lib/flowy-server-config/Cargo.toml index 8e11e35439fa..183271f43094 100644 --- a/frontend/rust-lib/flowy-server-config/Cargo.toml +++ b/frontend/rust-lib/flowy-server-config/Cargo.toml @@ -7,4 +7,4 @@ edition = "2021" [dependencies] flowy-error = { workspace = true } -serde = { version = "1.0", features = ["derive"] } \ No newline at end of file +serde.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-server-config/src/af_cloud_config.rs b/frontend/rust-lib/flowy-server-config/src/af_cloud_config.rs index b6c4b4c556d5..581976db99a5 100644 --- a/frontend/rust-lib/flowy-server-config/src/af_cloud_config.rs +++ b/frontend/rust-lib/flowy-server-config/src/af_cloud_config.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use serde::{Deserialize, Serialize}; use flowy_error::{ErrorCode, FlowyError}; @@ -13,6 +15,15 @@ pub struct AFCloudConfiguration { pub gotrue_url: String, } +impl Display for AFCloudConfiguration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!( + "base_url: {}, ws_base_url: {}, gotrue_url: {}", + self.base_url, self.ws_base_url, self.gotrue_url, + )) + } +} + impl AFCloudConfiguration { pub fn from_env() -> Result<Self, FlowyError> { let base_url = std::env::var(APPFLOWY_CLOUD_BASE_URL).map_err(|_| { @@ -32,6 +43,16 @@ impl AFCloudConfiguration { let gotrue_url = std::env::var(APPFLOWY_CLOUD_GOTRUE_URL) .map_err(|_| FlowyError::new(ErrorCode::InvalidAuthConfig, "Missing AF_CLOUD_GOTRUE_URL"))?; + if base_url.is_empty() || ws_base_url.is_empty() || gotrue_url.is_empty() { + return Err(FlowyError::new( + ErrorCode::InvalidAuthConfig, + format!( + "Invalid APPFLOWY_CLOUD_BASE_URL: {}, APPFLOWY_CLOUD_WS_BASE_URL: {}, APPFLOWY_CLOUD_GOTRUE_URL: {}", + base_url, ws_base_url, gotrue_url, + )), + ); + } + Ok(Self { base_url, ws_base_url, diff --git a/frontend/rust-lib/flowy-server/Cargo.toml b/frontend/rust-lib/flowy-server/Cargo.toml index ccdc0a7b5681..ed0224372bb7 100644 --- a/frontend/rust-lib/flowy-server/Cargo.toml +++ b/frontend/rust-lib/flowy-server/Cargo.toml @@ -6,24 +6,24 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -tracing = { version = "0.1" } -futures = "0.3.26" +tracing.workspace = true +futures.workspace = true futures-util = "0.3.26" reqwest = { version = "0.11.20", features = ["native-tls-vendored", "multipart", "blocking"] } hyper = "0.14" config = { version = "0.10.1", default-features = false, features = ["yaml"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde.workspace = true +serde_json.workspace = true serde-aux = "4.2.0" thiserror = "1.0" -tokio = { version = "1.26", features = ["sync"]} -parking_lot = "0.12" +tokio = { workspace = true, features = ["sync"]} +parking_lot.workspace = true lazy_static = "1.4.0" -bytes = { version = "1.5", features = ["serde"] } +bytes = { workspace = true, features = ["serde"] } tokio-retry = "0.3" -anyhow = "1.0" -uuid = { version = "1.3.3", features = ["v4"] } -chrono = { version = "0.4.31", default-features = false, features = ["clock", "serde"] } +anyhow.workspace = true +uuid.workspace = true +chrono = { workspace = true, default-features = false, features = ["clock", "serde"] } collab = { version = "0.1.0" } collab-plugins = { version = "0.1.0"} collab-document = { version = "0.1.0" } @@ -42,14 +42,15 @@ flowy-storage = { workspace = true } mime_guess = "2.0" url = "2.4" tokio-util = "0.7" -tokio-stream = { version = "0.1.14", features = ["sync"] } -client-api = { version = "0.1.0", features = ["collab-sync"] } +tokio-stream = { workspace = true, features = ["sync"] } +client-api = { version = "0.1.0", features = ["collab-sync", "test_util"] } +lib-dispatch = { workspace = true } [dev-dependencies] -uuid = { version = "1.3.3", features = ["v4"] } +uuid.workspace = true tracing-subscriber = { version = "0.3.3", features = ["env-filter"] } dotenv = "0.15.0" yrs = "0.16.5" assert-json-diff = "2.0.2" -serde_json = "1.0.104" +serde_json.workspace = true client-api = { version = "0.1.0" } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs index e5ec9038b45e..1a794f212f8d 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs @@ -2,6 +2,7 @@ use anyhow::Error; use client_api::entity::QueryCollabResult::{Failed, Success}; use client_api::entity::{BatchQueryCollab, BatchQueryCollabParams, QueryCollabParams}; use client_api::error::ErrorCode::RecordNotFound; +use collab::core::collab_plugin::EncodedCollabV1; use collab_entity::CollabType; use tracing::error; @@ -34,7 +35,7 @@ where collab_type, }; match try_get_client?.get_collab(params).await { - Ok(data) => Ok(vec![data]), + Ok(data) => Ok(vec![data.doc_state.to_vec()]), Err(err) => { if err.code == RecordNotFound { Ok(vec![]) @@ -71,7 +72,15 @@ where .0 .into_iter() .flat_map(|(object_id, result)| match result { - Success { blob } => Some((object_id, vec![blob])), + Success { encode_collab_v1 } => { + match EncodedCollabV1::decode_from_bytes(&encode_collab_v1) { + Ok(encode) => Some((object_id, vec![encode.doc_state.to_vec()])), + Err(err) => { + error!("Failed to decode collab: {}", err); + None + }, + } + }, Failed { error } => { error!("Failed to get {} update: {}", object_id, error); None diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs index 2c46cebbaef2..3541b11b4bc1 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs @@ -20,7 +20,7 @@ where &self, document_id: &str, workspace_id: &str, - ) -> FutureResult<Vec<Vec<u8>>, Error> { + ) -> FutureResult<Vec<Vec<u8>>, FlowyError> { let workspace_id = workspace_id.to_string(); let try_get_client = self.0.try_get_client(); let document_id = document_id.to_string(); @@ -33,7 +33,9 @@ where let data = try_get_client? .get_collab(params) .await - .map_err(FlowyError::from)?; + .map_err(FlowyError::from)? + .doc_state + .to_vec(); Ok(vec![data]) }) } @@ -61,11 +63,14 @@ where object_id: document_id.clone(), collab_type: CollabType::Document, }; - let updates = vec![try_get_client? + let doc_state = try_get_client? .get_collab(params) .await - .map_err(FlowyError::from)?]; - let document = Document::from_updates(CollabOrigin::Empty, updates, &document_id, vec![])?; + .map_err(FlowyError::from)? + .doc_state + .to_vec(); + let document = + Document::from_updates(CollabOrigin::Empty, vec![doc_state], &document_id, vec![])?; Ok(document.get_document_data().ok()) }) } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs index 0f4d6283a641..f7268c0e532f 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs @@ -1,10 +1,12 @@ -use anyhow::Error; +use anyhow::{anyhow, Error}; use client_api::entity::QueryCollabParams; use collab::core::origin::CollabOrigin; use collab_entity::CollabType; use flowy_error::FlowyError; -use flowy_folder_deps::cloud::{Folder, FolderCloudService, FolderData, FolderSnapshot, Workspace}; +use flowy_folder_deps::cloud::{ + Folder, FolderCloudService, FolderData, FolderSnapshot, Workspace, WorkspaceRecord, +}; use lib_infra::future::FutureResult; use crate::af_cloud::AFServer; @@ -16,10 +18,44 @@ where T: AFServer, { fn create_workspace(&self, _uid: i64, _name: &str) -> FutureResult<Workspace, Error> { - FutureResult::new(async move { todo!() }) + FutureResult::new(async move { Err(anyhow!("Not support yet")) }) } - fn get_folder_data(&self, workspace_id: &str) -> FutureResult<Option<FolderData>, Error> { + fn open_workspace(&self, workspace_id: &str) -> FutureResult<(), Error> { + let workspace_id = workspace_id.to_string(); + let try_get_client = self.0.try_get_client(); + FutureResult::new(async move { + let client = try_get_client?; + let _ = client.open_workspace(&workspace_id).await?; + Ok(()) + }) + } + + fn get_all_workspace(&self) -> FutureResult<Vec<WorkspaceRecord>, Error> { + let try_get_client = self.0.try_get_client(); + FutureResult::new(async move { + let client = try_get_client?; + let records = client + .get_user_workspace_info() + .await? + .workspaces + .into_iter() + .map(|af_workspace| WorkspaceRecord { + id: af_workspace.workspace_id.to_string(), + name: af_workspace.workspace_name, + created_at: af_workspace.created_at.timestamp(), + }) + .collect::<Vec<_>>(); + Ok(records) + }) + } + + fn get_folder_data( + &self, + workspace_id: &str, + uid: &i64, + ) -> FutureResult<Option<FolderData>, Error> { + let uid = *uid; let workspace_id = workspace_id.to_string(); let try_get_client = self.0.try_get_client(); FutureResult::new(async move { @@ -28,12 +64,19 @@ where workspace_id: workspace_id.clone(), collab_type: CollabType::Folder, }; - let updates = vec![try_get_client? + let doc_state = try_get_client? .get_collab(params) .await - .map_err(FlowyError::from)?]; - let folder = - Folder::from_collab_raw_data(CollabOrigin::Empty, updates, &workspace_id, vec![])?; + .map_err(FlowyError::from)? + .doc_state + .to_vec(); + let folder = Folder::from_collab_raw_data( + uid, + CollabOrigin::Empty, + vec![doc_state], + &workspace_id, + vec![], + )?; Ok(folder.get_folder_data()) }) } @@ -55,11 +98,13 @@ where workspace_id, collab_type: CollabType::Folder, }; - let update = try_get_client? + let doc_state = try_get_client? .get_collab(params) .await - .map_err(FlowyError::from)?; - Ok(vec![update]) + .map_err(FlowyError::from)? + .doc_state + .to_vec(); + Ok(vec![doc_state]) }) } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs index 256282e0ab97..6be4e7036fae 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs @@ -5,9 +5,10 @@ use anyhow::{anyhow, Error}; use client_api::entity::workspace_dto::{CreateWorkspaceMember, WorkspaceMemberChangeset}; use client_api::entity::{AFRole, AFWorkspace, InsertCollabParams, OAuthProvider}; use collab_entity::CollabObject; +use parking_lot::RwLock; use flowy_error::{ErrorCode, FlowyError}; -use flowy_user_deps::cloud::UserCloudService; +use flowy_user_deps::cloud::{UserCloudService, UserUpdate, UserUpdateReceiver}; use flowy_user_deps::entities::*; use lib_infra::box_any::BoxAny; use lib_infra::future::FutureResult; @@ -21,11 +22,15 @@ use crate::supabase::define::{USER_DEVICE_ID, USER_SIGN_IN_URL}; pub(crate) struct AFCloudUserAuthServiceImpl<T> { server: T, + user_change_recv: RwLock<Option<tokio::sync::mpsc::Receiver<UserUpdate>>>, } impl<T> AFCloudUserAuthServiceImpl<T> { - pub(crate) fn new(server: T) -> Self { - Self { server } + pub(crate) fn new(server: T, user_change_recv: tokio::sync::mpsc::Receiver<UserUpdate>) -> Self { + Self { + server, + user_change_recv: RwLock::new(Some(user_change_recv)), + } } } @@ -62,13 +67,27 @@ where let email = email.to_string(); let try_get_client = self.server.try_get_client(); FutureResult::new(async move { - // TODO(nathan): replace the admin_email and admin_password with encryption key - let admin_email = std::env::var("GOTRUE_ADMIN_EMAIL").unwrap(); - let admin_password = std::env::var("GOTRUE_ADMIN_PASSWORD").unwrap(); - let url = try_get_client? - .generate_sign_in_url_with_email(&admin_email, &admin_password, &email) - .await?; - Ok(url) + let client = try_get_client?; + let admin_email = std::env::var("GOTRUE_ADMIN_EMAIL").map_err(|_| { + anyhow!( + "GOTRUE_ADMIN_EMAIL is not set. Please set it to the admin email for the test server" + ) + })?; + let admin_password = std::env::var("GOTRUE_ADMIN_PASSWORD").map_err(|_| { + anyhow!( + "GOTRUE_ADMIN_PASSWORD is not set. Please set it to the admin password for the test server" + ) + })?; + let admin_client = + client_api::Client::new(client.base_url(), client.ws_addr(), client.gotrue_url()); + admin_client + .sign_in_password(&admin_email, &admin_password) + .await + .unwrap(); + + let action_link = admin_client.generate_sign_in_action_link(&email).await?; + let sign_in_url = client.extract_sign_in_url(&action_link).await?; + Ok(sign_in_url) }) } @@ -113,7 +132,17 @@ where }) } - fn get_all_user_workspaces(&self, _uid: i64) -> FutureResult<Vec<UserWorkspace>, Error> { + fn open_workspace(&self, workspace_id: &str) -> FutureResult<UserWorkspace, FlowyError> { + let try_get_client = self.server.try_get_client(); + let workspace_id = workspace_id.to_string(); + FutureResult::new(async move { + let client = try_get_client?; + let af_workspace = client.open_workspace(&workspace_id).await?; + Ok(to_user_workspace(af_workspace)) + }) + } + + fn get_all_workspace(&self, _uid: i64) -> FutureResult<Vec<UserWorkspace>, Error> { let try_get_client = self.server.try_get_client(); FutureResult::new(async move { let workspaces = try_get_client?.get_workspaces().await?; @@ -188,12 +217,14 @@ where } fn get_user_awareness_updates(&self, _uid: i64) -> FutureResult<Vec<Vec<u8>>, Error> { - // TODO(nathan): implement the RESTful API for this FutureResult::new(async { Ok(vec![]) }) } + fn subscribe_user_update(&self) -> Option<UserUpdateReceiver> { + self.user_change_recv.write().take() + } + fn reset_workspace(&self, _collab_object: CollabObject) -> FutureResult<(), Error> { - // TODO(nathan): implement the RESTful API for this FutureResult::new(async { Ok(()) }) } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs index 28fd37688c34..cda882766c12 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs @@ -3,8 +3,8 @@ use client_api::entity::auth_dto::{UpdateUserParams, UserMetaData}; use client_api::entity::{AFRole, AFUserProfile, AFWorkspaceMember}; use flowy_user_deps::entities::{ - AuthType, Role, UpdateUserProfileParams, UserProfile, WorkspaceMember, USER_METADATA_ICON_URL, - USER_METADATA_OPEN_AI_KEY, USER_METADATA_STABILITY_AI_KEY, + Authenticator, Role, UpdateUserProfileParams, UserProfile, WorkspaceMember, + USER_METADATA_ICON_URL, USER_METADATA_OPEN_AI_KEY, USER_METADATA_STABILITY_AI_KEY, }; use crate::af_cloud::impls::user::util::encryption_type_from_profile; @@ -57,7 +57,7 @@ pub fn user_profile_from_af_profile( openai_key: openai_key.unwrap_or_default(), stability_ai_key: stability_ai_key.unwrap_or_default(), workspace_id: profile.latest_workspace_id.to_string(), - auth_type: AuthType::AFCloud, + authenticator: Authenticator::AFCloud, encryption_type, uid: profile.uid, updated_at: profile.updated_at, diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/server.rs b/frontend/rust-lib/flowy-server/src/af_cloud/server.rs index f2257586b828..e95c0108806e 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/server.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/server.rs @@ -2,14 +2,16 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use anyhow::Error; +use client_api::collab_sync::collab_msg::CollabMessage; +use client_api::entity::UserMessage; use client_api::notify::{TokenState, TokenStateReceiver}; use client_api::ws::{ - BusinessID, WSClient, WSClientConfig, WSConnectStateReceiver, WebSocketChannel, + ConnectState, WSClient, WSClientConfig, WSConnectStateReceiver, WebSocketChannel, }; use client_api::Client; use tokio::sync::watch; use tokio_stream::wrappers::WatchStream; -use tracing::{error, info}; +use tracing::{error, event, info}; use flowy_database_deps::cloud::DatabaseCloudService; use flowy_document_deps::cloud::DocumentCloudService; @@ -17,8 +19,9 @@ use flowy_error::{ErrorCode, FlowyError}; use flowy_folder_deps::cloud::FolderCloudService; use flowy_server_config::af_cloud_config::AFCloudConfiguration; use flowy_storage::FileStorageService; -use flowy_user_deps::cloud::UserCloudService; +use flowy_user_deps::cloud::{UserCloudService, UserUpdate}; use flowy_user_deps::entities::UserTokenState; +use lib_dispatch::prelude::af_spawn; use lib_infra::future::FutureResult; use crate::af_cloud::impls::{ @@ -49,11 +52,7 @@ impl AFCloudServer { let token_state_rx = api_client.subscribe_token_state(); let enable_sync = Arc::new(AtomicBool::new(enable_sync)); - let ws_client = WSClient::new(WSClientConfig { - buffer_capacity: 100, - ping_per_secs: 8, - retry_connect_per_pings: 6, - }); + let ws_client = WSClient::new(WSClientConfig::default(), api_client.clone()); let ws_client = Arc::new(ws_client); let api_client = Arc::new(api_client); @@ -94,7 +93,7 @@ impl AppFlowyServer for AFCloudServer { let mut token_state_rx = self.client.subscribe_token_state(); let (watch_tx, watch_rx) = watch::channel(UserTokenState::Invalid); let weak_client = Arc::downgrade(&self.client); - tokio::spawn(async move { + af_spawn(async move { while let Ok(token_state) = token_state_rx.recv().await { if let Some(client) = weak_client.upgrade() { match token_state { @@ -121,9 +120,26 @@ impl AppFlowyServer for AFCloudServer { info!("{} cloud sync: {}", uid, enable); self.enable_sync.store(enable, Ordering::SeqCst); } + fn user_service(&self) -> Arc<dyn UserCloudService> { let server = AFServerImpl(self.get_client()); - Arc::new(AFCloudUserAuthServiceImpl::new(server)) + let mut user_change = self.ws_client.subscribe_user_changed(); + let (tx, rx) = tokio::sync::mpsc::channel(1); + tokio::spawn(async move { + while let Ok(user_message) = user_change.recv().await { + if let UserMessage::ProfileChange(change) = user_message { + let user_update = UserUpdate { + uid: change.uid, + name: change.name, + email: change.email, + encryption_sign: "".to_string(), + }; + let _ = tx.send(user_update).await; + } + } + }); + + Arc::new(AFCloudUserAuthServiceImpl::new(server, rx)) } fn folder_service(&self) -> Arc<dyn FolderCloudService> { @@ -141,20 +157,28 @@ impl AppFlowyServer for AFCloudServer { Arc::new(AFCloudDocumentCloudServiceImpl(server)) } + #[allow(clippy::type_complexity)] fn collab_ws_channel( &self, - object_id: &str, - ) -> FutureResult<Option<(Arc<WebSocketChannel>, WSConnectStateReceiver)>, anyhow::Error> { + _object_id: &str, + ) -> FutureResult< + Option<( + Arc<WebSocketChannel<CollabMessage>>, + WSConnectStateReceiver, + bool, + )>, + anyhow::Error, + > { if self.enable_sync.load(Ordering::SeqCst) { - let object_id = object_id.to_string(); + let object_id = _object_id.to_string(); let weak_ws_client = Arc::downgrade(&self.ws_client); FutureResult::new(async move { match weak_ws_client.upgrade() { None => Ok(None), Some(ws_client) => { - let channel = ws_client.subscribe(BusinessID::CollabId, object_id).ok(); + let channel = ws_client.subscribe_collab(object_id).ok(); let connect_state_recv = ws_client.subscribe_connect_state(); - Ok(channel.map(|c| (c, connect_state_recv))) + Ok(channel.map(|c| (c, connect_state_recv, ws_client.is_connected()))) }, } }) @@ -185,28 +209,37 @@ fn spawn_ws_conn( let weak_api_client = Arc::downgrade(api_client); let enable_sync = enable_sync.clone(); - tokio::spawn(async move { + af_spawn(async move { if let Some(ws_client) = weak_ws_client.upgrade() { let mut state_recv = ws_client.subscribe_connect_state(); while let Ok(state) = state_recv.recv().await { - if !state.is_timeout() { - continue; - } - - // Try to reconnect if the connection is timed out. - if let (Some(api_client), Some(device_id)) = - (weak_api_client.upgrade(), weak_device_id.upgrade()) - { - if enable_sync.load(Ordering::SeqCst) { - info!("🟢websocket state: {:?}, reconnecting", state); - let device_id = device_id.read().clone(); - match api_client.ws_url(&device_id) { - Ok(ws_addr) => { - let _ = ws_client.connect(ws_addr).await; - }, - Err(err) => error!("Failed to get ws url: {}", err), + info!("[websocket] state: {:?}", state); + match state { + ConnectState::PingTimeout | ConnectState::Closed => { + // Try to reconnect if the connection is timed out. + if let (Some(api_client), Some(device_id)) = + (weak_api_client.upgrade(), weak_device_id.upgrade()) + { + if enable_sync.load(Ordering::SeqCst) { + let device_id = device_id.read().clone(); + match api_client.ws_url(&device_id) { + Ok(ws_addr) => { + event!(tracing::Level::INFO, "🟢reconnecting websocket"); + let _ = ws_client.connect(ws_addr, &device_id).await; + }, + Err(err) => error!("Failed to get ws url: {}", err), + } + } } - } + }, + ConnectState::Unauthorized => { + if let Some(api_client) = weak_api_client.upgrade() { + if let Err(err) = api_client.refresh_token().await { + error!("Failed to refresh token: {}", err); + } + } + }, + _ => {}, } } } @@ -215,7 +248,7 @@ fn spawn_ws_conn( let weak_device_id = Arc::downgrade(device_id); let weak_ws_client = Arc::downgrade(ws_client); let weak_api_client = Arc::downgrade(api_client); - tokio::spawn(async move { + af_spawn(async move { while let Ok(token_state) = token_state_rx.recv().await { match token_state { TokenState::Refresh => { @@ -228,7 +261,7 @@ fn spawn_ws_conn( match api_client.ws_url(&device_id) { Ok(ws_addr) => { info!("🟢token state: {:?}, reconnecting websocket", token_state); - let _ = ws_client.connect(ws_addr).await; + let _ = ws_client.connect(ws_addr, &device_id).await; }, Err(err) => error!("Failed to get ws url: {}", err), } diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs index 6797270a0056..b392c9d28bb8 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs @@ -1,6 +1,7 @@ use anyhow::Error; use flowy_document_deps::cloud::*; +use flowy_error::FlowyError; use lib_infra::future::FutureResult; pub(crate) struct LocalServerDocumentCloudServiceImpl(); @@ -10,7 +11,7 @@ impl DocumentCloudService for LocalServerDocumentCloudServiceImpl { &self, _document_id: &str, _workspace_id: &str, - ) -> FutureResult<Vec<Vec<u8>>, Error> { + ) -> FutureResult<Vec<Vec<u8>>, FlowyError> { FutureResult::new(async move { Ok(vec![]) }) } diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs index b550aeaa0e92..99f00e2d2709 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs @@ -3,31 +3,42 @@ use std::sync::Arc; use anyhow::Error; use flowy_folder_deps::cloud::{ - gen_workspace_id, FolderCloudService, FolderData, FolderSnapshot, Workspace, + gen_workspace_id, FolderCloudService, FolderData, FolderSnapshot, Workspace, WorkspaceRecord, }; use lib_infra::future::FutureResult; -use lib_infra::util::timestamp; use crate::local_server::LocalServerDB; pub(crate) struct LocalServerFolderCloudServiceImpl { + #[allow(dead_code)] pub db: Arc<dyn LocalServerDB>, } impl FolderCloudService for LocalServerFolderCloudServiceImpl { - fn create_workspace(&self, _uid: i64, name: &str) -> FutureResult<Workspace, Error> { + fn create_workspace(&self, uid: i64, name: &str) -> FutureResult<Workspace, Error> { let name = name.to_string(); FutureResult::new(async move { - Ok(Workspace { - id: gen_workspace_id().to_string(), - name: name.to_string(), - child_views: Default::default(), - created_at: timestamp(), - }) + Ok(Workspace::new( + gen_workspace_id().to_string(), + name.to_string(), + uid, + )) }) } - fn get_folder_data(&self, _workspace_id: &str) -> FutureResult<Option<FolderData>, Error> { + fn open_workspace(&self, _workspace_id: &str) -> FutureResult<(), Error> { + FutureResult::new(async { Ok(()) }) + } + + fn get_all_workspace(&self) -> FutureResult<Vec<WorkspaceRecord>, Error> { + FutureResult::new(async { Ok(vec![]) }) + } + + fn get_folder_data( + &self, + _workspace_id: &str, + _uid: &i64, + ) -> FutureResult<Option<FolderData>, Error> { FutureResult::new(async move { Ok(None) }) } @@ -39,18 +50,12 @@ impl FolderCloudService for LocalServerFolderCloudServiceImpl { FutureResult::new(async move { Ok(vec![]) }) } - fn get_folder_updates(&self, workspace_id: &str, uid: i64) -> FutureResult<Vec<Vec<u8>>, Error> { - let weak_db = Arc::downgrade(&self.db); - let workspace_id = workspace_id.to_string(); - FutureResult::new(async move { - match weak_db.upgrade() { - None => Ok(vec![]), - Some(db) => { - let updates = db.get_collab_updates(uid, &workspace_id)?; - Ok(updates) - }, - } - }) + fn get_folder_updates( + &self, + _workspace_id: &str, + _uid: i64, + ) -> FutureResult<Vec<Vec<u8>>, Error> { + FutureResult::new(async move { Ok(vec![]) }) } fn service_name(&self) -> String { diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs index fcdf519ace8e..fbb624739cd9 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs @@ -116,7 +116,13 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { FutureResult::new(async { result }) } - fn get_all_user_workspaces(&self, _uid: i64) -> FutureResult<Vec<UserWorkspace>, Error> { + fn open_workspace(&self, _workspace_id: &str) -> FutureResult<UserWorkspace, FlowyError> { + FutureResult::new(async { + Err(FlowyError::not_support().with_context("local server doesn't support open workspace")) + }) + } + + fn get_all_workspace(&self, _uid: i64) -> FutureResult<Vec<UserWorkspace>, Error> { FutureResult::new(async { Ok(vec![]) }) } diff --git a/frontend/rust-lib/flowy-server/src/local_server/server.rs b/frontend/rust-lib/flowy-server/src/local_server/server.rs index af300a6e6986..8215dae2f5cf 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/server.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/server.rs @@ -23,7 +23,6 @@ use crate::AppFlowyServer; pub trait LocalServerDB: Send + Sync + 'static { fn get_user_profile(&self, uid: i64) -> Result<UserProfile, FlowyError>; fn get_user_workspace(&self, uid: i64) -> Result<Option<UserWorkspace>, FlowyError>; - fn get_collab_updates(&self, uid: i64, object_id: &str) -> Result<Vec<Vec<u8>>, FlowyError>; } pub struct LocalServer { diff --git a/frontend/rust-lib/flowy-server/src/server.rs b/frontend/rust-lib/flowy-server/src/server.rs index 9effd441af97..945dbd9e9f41 100644 --- a/frontend/rust-lib/flowy-server/src/server.rs +++ b/frontend/rust-lib/flowy-server/src/server.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use anyhow::Error; +use client_api::collab_sync::collab_msg::CollabMessage; use client_api::ws::{WSConnectStateReceiver, WebSocketChannel}; use collab_entity::CollabObject; use collab_plugins::cloud_storage::RemoteCollabStorage; @@ -101,10 +102,18 @@ pub trait AppFlowyServer: Send + Sync + 'static { None } + #[allow(clippy::type_complexity)] fn collab_ws_channel( &self, _object_id: &str, - ) -> FutureResult<Option<(Arc<WebSocketChannel>, WSConnectStateReceiver)>, anyhow::Error> { + ) -> FutureResult< + Option<( + Arc<WebSocketChannel<CollabMessage>>, + WSConnectStateReceiver, + bool, + )>, + anyhow::Error, + > { FutureResult::new(async { Ok(None) }) } diff --git a/frontend/rust-lib/flowy-server/src/supabase/api/database.rs b/frontend/rust-lib/flowy-server/src/supabase/api/database.rs index 963978c2fca2..8b38ad62fda9 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/database.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/database.rs @@ -5,6 +5,7 @@ use tokio::sync::oneshot::channel; use flowy_database_deps::cloud::{ CollabObjectUpdate, CollabObjectUpdateByOid, DatabaseCloudService, DatabaseSnapshot, }; +use lib_dispatch::prelude::af_spawn; use lib_infra::future::FutureResult; use crate::supabase::api::request::{ @@ -35,7 +36,7 @@ where let try_get_postgrest = self.server.try_get_weak_postgrest(); let object_id = object_id.to_string(); let (tx, rx) = channel(); - tokio::spawn(async move { + af_spawn(async move { tx.send( async move { let postgrest = try_get_postgrest?; @@ -58,7 +59,7 @@ where ) -> FutureResult<CollabObjectUpdateByOid, Error> { let try_get_postgrest = self.server.try_get_weak_postgrest(); let (tx, rx) = channel(); - tokio::spawn(async move { + af_spawn(async move { tx.send( async move { let postgrest = try_get_postgrest?; diff --git a/frontend/rust-lib/flowy-server/src/supabase/api/document.rs b/frontend/rust-lib/flowy-server/src/supabase/api/document.rs index 9968a1c44b2d..82ea76ca8fd9 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/document.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/document.rs @@ -7,6 +7,7 @@ use tokio::sync::oneshot::channel; use flowy_document_deps::cloud::{DocumentCloudService, DocumentSnapshot}; use flowy_error::FlowyError; +use lib_dispatch::prelude::af_spawn; use lib_infra::future::FutureResult; use crate::supabase::api::request::{get_snapshots_from_server, FetchObjectUpdateAction}; @@ -31,18 +32,18 @@ where &self, document_id: &str, workspace_id: &str, - ) -> FutureResult<Vec<Vec<u8>>, Error> { + ) -> FutureResult<Vec<Vec<u8>>, FlowyError> { let try_get_postgrest = self.server.try_get_weak_postgrest(); let document_id = document_id.to_string(); let (tx, rx) = channel(); - tokio::spawn(async move { + af_spawn(async move { tx.send( async move { let postgrest = try_get_postgrest?; let action = FetchObjectUpdateAction::new(document_id, CollabType::Document, postgrest); let updates = action.run_with_fix_interval(5, 10).await?; if updates.is_empty() { - return Err(FlowyError::collab_not_sync().into()); + return Err(FlowyError::collab_not_sync()); } Ok(updates) } @@ -85,7 +86,7 @@ where let try_get_postgrest = self.server.try_get_weak_postgrest(); let document_id = document_id.to_string(); let (tx, rx) = channel(); - tokio::spawn(async move { + af_spawn(async move { tx.send( async move { let postgrest = try_get_postgrest?; diff --git a/frontend/rust-lib/flowy-server/src/supabase/api/folder.rs b/frontend/rust-lib/flowy-server/src/supabase/api/folder.rs index a8147792c31c..6efe86d1d5c7 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/folder.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/folder.rs @@ -9,8 +9,11 @@ use tokio::sync::oneshot::channel; use flowy_folder_deps::cloud::{ gen_workspace_id, Folder, FolderCloudService, FolderData, FolderSnapshot, Workspace, + WorkspaceRecord, }; +use lib_dispatch::prelude::af_spawn; use lib_infra::future::FutureResult; +use lib_infra::util::timestamp; use crate::response::ExtendedResponse; use crate::supabase::api::request::{ @@ -68,7 +71,20 @@ where }) } - fn get_folder_data(&self, workspace_id: &str) -> FutureResult<Option<FolderData>, Error> { + fn open_workspace(&self, _workspace_id: &str) -> FutureResult<(), Error> { + FutureResult::new(async { Ok(()) }) + } + + fn get_all_workspace(&self) -> FutureResult<Vec<WorkspaceRecord>, Error> { + FutureResult::new(async { Ok(vec![]) }) + } + + fn get_folder_data( + &self, + workspace_id: &str, + uid: &i64, + ) -> FutureResult<Option<FolderData>, Error> { + let uid = *uid; let try_get_postgrest = self.server.try_get_postgrest(); let workspace_id = workspace_id.to_string(); FutureResult::new(async move { @@ -84,7 +100,7 @@ where } let folder = - Folder::from_collab_raw_data(CollabOrigin::Empty, updates, &workspace_id, vec![])?; + Folder::from_collab_raw_data(uid, CollabOrigin::Empty, updates, &workspace_id, vec![])?; Ok(folder.get_folder_data()) }) } @@ -116,7 +132,7 @@ where let try_get_postgrest = self.server.try_get_weak_postgrest(); let workspace_id = workspace_id.to_string(); let (tx, rx) = channel(); - tokio::spawn(async move { + af_spawn(async move { tx.send( async move { let postgrest = try_get_postgrest?; @@ -155,5 +171,11 @@ fn workspace_from_json_value(value: Value) -> Result<Workspace, Error> { .and_then(|s| DateTime::<Utc>::from_str(s).ok()) .map(|date| date.timestamp()) .unwrap_or_default(), + created_by: json.get("created_by").and_then(|value| value.as_i64()), + last_edited_time: json + .get("last_edited_time") + .and_then(|value| value.as_i64()) + .unwrap_or(timestamp()), + last_edited_by: json.get("last_edited_by").and_then(|value| value.as_i64()), }) } diff --git a/frontend/rust-lib/flowy-server/src/supabase/api/user.rs b/frontend/rust-lib/flowy-server/src/supabase/api/user.rs index a08f6f3ef0e7..8ce724389239 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/user.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/user.rs @@ -17,13 +17,13 @@ use tokio_retry::{Action, RetryIf}; use uuid::Uuid; use flowy_error::FlowyError; -use flowy_folder_deps::cloud::{Folder, Workspace}; +use flowy_folder_deps::cloud::{Folder, FolderData, Workspace}; use flowy_user_deps::cloud::*; use flowy_user_deps::entities::*; use flowy_user_deps::DEFAULT_USER_NAME; +use lib_dispatch::prelude::af_spawn; use lib_infra::box_any::BoxAny; use lib_infra::future::FutureResult; -use lib_infra::util::timestamp; use crate::response::ExtendedResponse; use crate::supabase::api::request::{ @@ -43,19 +43,19 @@ use crate::AppFlowyEncryption; pub struct SupabaseUserServiceImpl<T> { server: T, realtime_event_handlers: Vec<Box<dyn RealtimeEventHandler>>, - user_update_tx: Option<UserUpdateSender>, + user_update_rx: RwLock<Option<UserUpdateReceiver>>, } impl<T> SupabaseUserServiceImpl<T> { pub fn new( server: T, realtime_event_handlers: Vec<Box<dyn RealtimeEventHandler>>, - user_update_tx: Option<UserUpdateSender>, + user_update_rx: Option<UserUpdateReceiver>, ) -> Self { Self { server, realtime_event_handlers, - user_update_tx, + user_update_rx: RwLock::new(user_update_rx), } } } @@ -218,7 +218,7 @@ where openai_key: "".to_string(), stability_ai_key: "".to_string(), workspace_id: response.latest_workspace_id, - auth_type: AuthType::Supabase, + authenticator: Authenticator::Supabase, encryption_type: EncryptionType::from_sign(&response.encryption_sign), updated_at: response.updated_at.timestamp(), }), @@ -226,7 +226,13 @@ where }) } - fn get_all_user_workspaces(&self, uid: i64) -> FutureResult<Vec<UserWorkspace>, Error> { + fn open_workspace(&self, _workspace_id: &str) -> FutureResult<UserWorkspace, FlowyError> { + FutureResult::new(async { + Err(FlowyError::not_support().with_context("supabase server doesn't support open workspace")) + }) + } + + fn get_all_workspace(&self, uid: i64) -> FutureResult<Vec<UserWorkspace>, Error> { let try_get_postgrest = self.server.try_get_postgrest(); FutureResult::new(async move { let postgrest = try_get_postgrest?; @@ -238,7 +244,7 @@ where let try_get_postgrest = self.server.try_get_weak_postgrest(); let awareness_id = uid.to_string(); let (tx, rx) = channel(); - tokio::spawn(async move { + af_spawn(async move { tx.send( async move { let postgrest = try_get_postgrest?; @@ -269,7 +275,7 @@ where } fn subscribe_user_update(&self) -> Option<UserUpdateReceiver> { - self.user_update_tx.as_ref().map(|tx| tx.subscribe()) + self.user_update_rx.write().take() } fn reset_workspace(&self, collab_object: CollabObject) -> FutureResult<(), Error> { @@ -277,8 +283,8 @@ where let try_get_postgrest = self.server.try_get_weak_postgrest(); let (tx, rx) = channel(); - let init_update = empty_workspace_update(&collab_object); - tokio::spawn(async move { + let init_update = default_workspace_doc_state(&collab_object); + af_spawn(async move { tx.send( async move { let postgrest = try_get_postgrest? @@ -316,7 +322,7 @@ where let try_get_postgrest = self.server.try_get_weak_postgrest(); let cloned_collab_object = collab_object.clone(); let (tx, rx) = channel(); - tokio::spawn(async move { + af_spawn(async move { tx.send( async move { CreateCollabAction::new(cloned_collab_object, try_get_postgrest?, update) @@ -525,8 +531,8 @@ impl RealtimeEventHandler for RealtimeUserHandler { if let Ok(user_event) = serde_json::from_value::<RealtimeUserEvent>(event.new.clone()) { let _ = self.0.send(UserUpdate { uid: user_event.uid, - name: user_event.name, - email: user_event.email, + name: Some(user_event.name), + email: Some(user_event.email), encryption_sign: user_event.encryption_sign, }); } @@ -601,22 +607,16 @@ impl RealtimeEventHandler for RealtimeCollabUpdateHandler { } } -fn empty_workspace_update(collab_object: &CollabObject) -> Vec<u8> { +fn default_workspace_doc_state(collab_object: &CollabObject) -> Vec<u8> { let workspace_id = collab_object.object_id.clone(); let collab = Arc::new(MutexCollab::new( CollabOrigin::Empty, &collab_object.object_id, vec![], )); - let folder = Folder::create(collab.clone(), None, None); - folder.workspaces.create_workspace(Workspace { - id: workspace_id.clone(), - name: "My workspace".to_string(), - child_views: Default::default(), - created_at: timestamp(), - }); - folder.set_current_workspace(&workspace_id); - collab.encode_as_update_v1().0 + let workspace = Workspace::new(workspace_id, "My workspace".to_string(), collab_object.uid); + let folder = Folder::create(collab_object.uid, collab, None, FolderData::new(workspace)); + folder.encode_collab_v1().doc_state.to_vec() } fn oauth_params_from_box_any(any: BoxAny) -> Result<SupabaseOAuthParams, Error> { diff --git a/frontend/rust-lib/flowy-server/src/supabase/server.rs b/frontend/rust-lib/flowy-server/src/supabase/server.rs index 7d917ae962f1..71bb3456c24f 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/server.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/server.rs @@ -137,7 +137,7 @@ impl AppFlowyServer for SupabaseServer { fn user_service(&self) -> Arc<dyn UserCloudService> { // handle the realtime collab update event. - let (user_update_tx, _) = tokio::sync::broadcast::channel(100); + let (user_update_tx, user_update_rx) = tokio::sync::mpsc::channel(1); let collab_update_handler = Box::new(RealtimeCollabUpdateHandler::new( Arc::downgrade(&self.collab_update_sender), @@ -152,7 +152,7 @@ impl AppFlowyServer for SupabaseServer { Arc::new(SupabaseUserServiceImpl::new( SupabaseServerServiceImpl(self.restful_postgres.clone()), handlers, - Some(user_update_tx), + Some(user_update_rx), )) } diff --git a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs index 88d3a59ace8b..765a5e73f371 100644 --- a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs +++ b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs @@ -10,6 +10,14 @@ use flowy_server_config::af_cloud_config::AFCloudConfiguration; use crate::setup_log; +/// To run the test, create a .env.ci file in the 'flowy-server' directory and set the following environment variables: +/// +/// - `APPFLOWY_CLOUD_BASE_URL=http://localhost:8000` +/// - `APPFLOWY_CLOUD_WS_BASE_URL=ws://localhost:8000/ws` +/// - `APPFLOWY_CLOUD_GOTRUE_URL=http://localhost:9998` +/// +/// - `GOTRUE_ADMIN_EMAIL=admin@example.com` +/// - `GOTRUE_ADMIN_PASSWORD=password` pub fn get_af_cloud_config() -> Option<AFCloudConfiguration> { dotenv::from_filename("./.env.ci").ok()?; setup_log(); @@ -23,15 +31,21 @@ pub fn af_cloud_server(config: AFCloudConfiguration) -> Arc<AFCloudServer> { } pub async fn generate_sign_in_url(user_email: &str, config: &AFCloudConfiguration) -> String { - let api_client = - client_api::Client::new(&config.base_url, &config.ws_base_url, &config.gotrue_url); - + let client = client_api::Client::new(&config.base_url, &config.ws_base_url, &config.gotrue_url); let admin_email = std::env::var("GOTRUE_ADMIN_EMAIL").unwrap(); let admin_password = std::env::var("GOTRUE_ADMIN_PASSWORD").unwrap(); - api_client - .generate_sign_in_url_with_email(&admin_email, &admin_password, user_email) + let admin_client = + client_api::Client::new(client.base_url(), client.ws_addr(), client.gotrue_url()); + admin_client + .sign_in_password(&admin_email, &admin_password) + .await + .unwrap(); + + let action_link = admin_client + .generate_sign_in_action_link(&user_email) .await - .unwrap() + .unwrap(); + client.extract_sign_in_url(&action_link).await.unwrap() } pub async fn af_cloud_sign_up_param( diff --git a/frontend/rust-lib/flowy-server/tests/supabase_test/util.rs b/frontend/rust-lib/flowy-server/tests/supabase_test/util.rs index b22fe1b92530..d79537a07c9c 100644 --- a/frontend/rust-lib/flowy-server/tests/supabase_test/util.rs +++ b/frontend/rust-lib/flowy-server/tests/supabase_test/util.rs @@ -96,15 +96,23 @@ pub fn encryption_collab_service( } #[allow(dead_code)] -pub async fn print_encryption_folder(folder_id: &str, encryption_secret: Option<String>) { +pub async fn print_encryption_folder( + uid: &i64, + folder_id: &str, + encryption_secret: Option<String>, +) { let (cloud_service, _encryption) = encryption_folder_service(encryption_secret); - let folder_data = cloud_service.get_folder_data(folder_id).await.unwrap(); + let folder_data = cloud_service.get_folder_data(folder_id, uid).await.unwrap(); let json = serde_json::to_value(folder_data).unwrap(); println!("{}", serde_json::to_string_pretty(&json).unwrap()); } #[allow(dead_code)] -pub async fn print_encryption_folder_snapshot(folder_id: &str, encryption_secret: Option<String>) { +pub async fn print_encryption_folder_snapshot( + uid: &i64, + folder_id: &str, + encryption_secret: Option<String>, +) { let (cloud_service, _encryption) = encryption_collab_service(encryption_secret); let snapshot = cloud_service .get_snapshots(folder_id, 1) @@ -115,7 +123,10 @@ pub async fn print_encryption_folder_snapshot(folder_id: &str, encryption_secret MutexCollab::new_with_raw_data(CollabOrigin::Empty, folder_id, vec![snapshot.blob], vec![]) .unwrap(), ); - let folder_data = Folder::open(collab, None).get_folder_data().unwrap(); + let folder_data = Folder::open(uid, collab, None) + .unwrap() + .get_folder_data() + .unwrap(); let json = serde_json::to_value(folder_data).unwrap(); println!("{}", serde_json::to_string_pretty(&json).unwrap()); } diff --git a/frontend/rust-lib/flowy-sqlite/Cargo.toml b/frontend/rust-lib/flowy-sqlite/Cargo.toml index 810be1006553..b6639960d571 100644 --- a/frontend/rust-lib/flowy-sqlite/Cargo.toml +++ b/frontend/rust-lib/flowy-sqlite/Cargo.toml @@ -6,15 +6,15 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -diesel = { version = "1.4.8", features = ["sqlite", "chrono"] } +diesel.workspace = true diesel_derives = { version = "1.4.1", features = ["sqlite"] } diesel_migrations = { version = "1.4.0", features = ["sqlite"] } -tracing = { version = "0.1", features = ["log"] } +tracing.workspace = true lazy_static = "1.4.0" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -anyhow = "1.0" -parking_lot = "0.12.1" +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +parking_lot.workspace = true r2d2 = "0.8.10" libsqlite3-sys = { version = ">=0.8.0, <0.24.0", features = ["bundled"] } diff --git a/frontend/rust-lib/flowy-sqlite/src/kv/kv.rs b/frontend/rust-lib/flowy-sqlite/src/kv/kv.rs index 6348ebd536c5..423ad5db92c9 100644 --- a/frontend/rust-lib/flowy-sqlite/src/kv/kv.rs +++ b/frontend/rust-lib/flowy-sqlite/src/kv/kv.rs @@ -7,7 +7,7 @@ use serde::de::DeserializeOwned; use serde::Serialize; use crate::kv::schema::{kv_table, kv_table::dsl, KV_SQL}; -use crate::sqlite::{Database, PoolConfig}; +use crate::sqlite_impl::{Database, PoolConfig}; const DB_NAME: &str = "cache.db"; diff --git a/frontend/rust-lib/flowy-sqlite/src/lib.rs b/frontend/rust-lib/flowy-sqlite/src/lib.rs index fbdc5431d148..85982bb5e429 100644 --- a/frontend/rust-lib/flowy-sqlite/src/lib.rs +++ b/frontend/rust-lib/flowy-sqlite/src/lib.rs @@ -10,11 +10,11 @@ use std::{fmt::Debug, io, path::Path}; pub use diesel::*; pub use diesel_derives::*; -use crate::sqlite::PoolConfig; -pub use crate::sqlite::{ConnectionPool, DBConnection, Database}; +use crate::sqlite_impl::PoolConfig; +pub use crate::sqlite_impl::{ConnectionPool, DBConnection, Database}; pub mod kv; -mod sqlite; +mod sqlite_impl; pub mod schema; @@ -46,7 +46,7 @@ pub fn init<P: AsRef<Path>>(storage_path: P) -> Result<Database, io::Error> { fn as_io_error<E>(e: E) -> io::Error where - E: Into<crate::sqlite::Error> + Debug, + E: Into<crate::sqlite_impl::Error> + Debug, { let msg = format!("{:?}", e); io::Error::new(io::ErrorKind::NotConnected, msg) diff --git a/frontend/rust-lib/flowy-sqlite/src/sqlite/conn_ext.rs b/frontend/rust-lib/flowy-sqlite/src/sqlite_impl/conn_ext.rs similarity index 94% rename from frontend/rust-lib/flowy-sqlite/src/sqlite/conn_ext.rs rename to frontend/rust-lib/flowy-sqlite/src/sqlite_impl/conn_ext.rs index 31bfd109a11c..90a55b82aad3 100644 --- a/frontend/rust-lib/flowy-sqlite/src/sqlite/conn_ext.rs +++ b/frontend/rust-lib/flowy-sqlite/src/sqlite_impl/conn_ext.rs @@ -1,8 +1,9 @@ -use crate::sqlite::errors::*; use diesel::{ dsl::sql, expression::SqlLiteral, query_dsl::LoadQuery, Connection, RunQueryDsl, SqliteConnection, }; +use crate::sqlite_impl::errors::*; + pub trait ConnectionExtension: Connection { fn query<ST, T>(&self, query: &str) -> Result<T> where diff --git a/frontend/rust-lib/flowy-sqlite/src/sqlite/database.rs b/frontend/rust-lib/flowy-sqlite/src/sqlite_impl/database.rs similarity index 97% rename from frontend/rust-lib/flowy-sqlite/src/sqlite/database.rs rename to frontend/rust-lib/flowy-sqlite/src/sqlite_impl/database.rs index a78e8a48b5a9..cc5300b335eb 100644 --- a/frontend/rust-lib/flowy-sqlite/src/sqlite/database.rs +++ b/frontend/rust-lib/flowy-sqlite/src/sqlite_impl/database.rs @@ -1,9 +1,11 @@ -use crate::sqlite::{ +use std::sync::Arc; + +use r2d2::PooledConnection; + +use crate::sqlite_impl::{ errors::*, pool::{ConnectionManager, ConnectionPool, PoolConfig}, }; -use r2d2::PooledConnection; -use std::sync::Arc; pub struct Database { uri: String, diff --git a/frontend/rust-lib/flowy-sqlite/src/sqlite/errors.rs b/frontend/rust-lib/flowy-sqlite/src/sqlite_impl/errors.rs similarity index 100% rename from frontend/rust-lib/flowy-sqlite/src/sqlite/errors.rs rename to frontend/rust-lib/flowy-sqlite/src/sqlite_impl/errors.rs diff --git a/frontend/rust-lib/flowy-sqlite/src/sqlite/mod.rs b/frontend/rust-lib/flowy-sqlite/src/sqlite_impl/mod.rs similarity index 100% rename from frontend/rust-lib/flowy-sqlite/src/sqlite/mod.rs rename to frontend/rust-lib/flowy-sqlite/src/sqlite_impl/mod.rs diff --git a/frontend/rust-lib/flowy-sqlite/src/sqlite/pool.rs b/frontend/rust-lib/flowy-sqlite/src/sqlite_impl/pool.rs similarity index 95% rename from frontend/rust-lib/flowy-sqlite/src/sqlite/pool.rs rename to frontend/rust-lib/flowy-sqlite/src/sqlite_impl/pool.rs index e14ea9d7a820..8dc0eb3e6db1 100644 --- a/frontend/rust-lib/flowy-sqlite/src/sqlite/pool.rs +++ b/frontend/rust-lib/flowy-sqlite/src/sqlite_impl/pool.rs @@ -4,7 +4,7 @@ use diesel::{connection::Connection, SqliteConnection}; use r2d2::{CustomizeConnection, ManageConnection, Pool}; use scheduled_thread_pool::ScheduledThreadPool; -use crate::sqlite::{errors::*, pragma::*}; +use crate::sqlite_impl::{errors::*, pragma::*}; pub struct ConnectionPool { pub(crate) inner: Pool<ConnectionManager>, @@ -87,7 +87,7 @@ pub struct ConnectionManager { impl ManageConnection for ConnectionManager { type Connection = SqliteConnection; - type Error = crate::sqlite::Error; + type Error = crate::sqlite_impl::Error; fn connect(&self) -> Result<Self::Connection> { Ok(SqliteConnection::establish(&self.db_uri)?) @@ -142,7 +142,7 @@ impl DatabaseCustomizer { } } -impl CustomizeConnection<SqliteConnection, crate::sqlite::Error> for DatabaseCustomizer { +impl CustomizeConnection<SqliteConnection, crate::sqlite_impl::Error> for DatabaseCustomizer { fn on_acquire(&self, conn: &mut SqliteConnection) -> Result<()> { conn.pragma_set_busy_timeout(self.config.busy_timeout)?; if self.config.journal_mode != SQLiteJournalMode::WAL { diff --git a/frontend/rust-lib/flowy-sqlite/src/sqlite/pragma.rs b/frontend/rust-lib/flowy-sqlite/src/sqlite_impl/pragma.rs similarity index 97% rename from frontend/rust-lib/flowy-sqlite/src/sqlite/pragma.rs rename to frontend/rust-lib/flowy-sqlite/src/sqlite_impl/pragma.rs index d773d2d25e0b..51830197c12d 100644 --- a/frontend/rust-lib/flowy-sqlite/src/sqlite/pragma.rs +++ b/frontend/rust-lib/flowy-sqlite/src/sqlite_impl/pragma.rs @@ -1,5 +1,11 @@ #![allow(clippy::upper_case_acronyms)] -use crate::sqlite::errors::{Error, Result}; + +use std::{ + convert::{TryFrom, TryInto}, + fmt, + str::FromStr, +}; + use diesel::{ expression::SqlLiteral, query_dsl::load_dsl::LoadQuery, @@ -7,12 +13,8 @@ use diesel::{ SqliteConnection, }; -use crate::sqlite::conn_ext::ConnectionExtension; -use std::{ - convert::{TryFrom, TryInto}, - fmt, - str::FromStr, -}; +use crate::sqlite_impl::conn_ext::ConnectionExtension; +use crate::sqlite_impl::errors::{Error, Result}; pub trait PragmaExtension: ConnectionExtension { fn pragma<D: std::fmt::Display>(&self, key: &str, val: D, schema: Option<&str>) -> Result<()> { diff --git a/frontend/rust-lib/flowy-storage/Cargo.toml b/frontend/rust-lib/flowy-storage/Cargo.toml index 1387ec98acc0..3fef4854464d 100644 --- a/frontend/rust-lib/flowy-storage/Cargo.toml +++ b/frontend/rust-lib/flowy-storage/Cargo.toml @@ -7,10 +7,10 @@ edition = "2021" [dependencies] reqwest = { version = "0.11", features = ["json", "stream"] } -serde_json = "1.0" -serde = { version = "1.0", features = ["derive"] } -async-trait = "0.1.73" -bytes = "1.0.1" +serde_json.workspace = true +serde.workspace = true +async-trait.workspace = true +bytes.workspace = true mime_guess = "2.0" lib-infra = { path = "../../../shared-lib/lib-infra" } url = "2.2.2" diff --git a/frontend/rust-lib/flowy-task/Cargo.toml b/frontend/rust-lib/flowy-task/Cargo.toml index ee3c749e2697..e28795eff28f 100644 --- a/frontend/rust-lib/flowy-task/Cargo.toml +++ b/frontend/rust-lib/flowy-task/Cargo.toml @@ -7,11 +7,11 @@ edition = "2021" [dependencies] lib-infra = { path = "../../../shared-lib/lib-infra" } -tokio = { version = "1.26", features = ["sync", "macros", ]} +tokio = { workspace = true, features = ["sync", "macros", ]} atomic_refcell = "0.1.9" -anyhow = "1.0" -tracing = { version = "0.1", features = ["log"] } +anyhow.workspace = true +tracing.workspace = true [dev-dependencies] rand = "0.8.5" -futures = "0.3.26" +futures.workspace = true diff --git a/frontend/rust-lib/flowy-task/src/scheduler.rs b/frontend/rust-lib/flowy-task/src/scheduler.rs index 70d861a9cfba..c7e54094726d 100644 --- a/frontend/rust-lib/flowy-task/src/scheduler.rs +++ b/frontend/rust-lib/flowy-task/src/scheduler.rs @@ -1,17 +1,17 @@ -use crate::queue::TaskQueue; -use crate::store::TaskStore; -use crate::{Task, TaskContent, TaskId, TaskState}; -use anyhow::Error; - -use lib_infra::future::BoxResultFuture; - use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; +use anyhow::Error; use tokio::sync::{watch, RwLock}; use tokio::time::interval; +use lib_infra::future::BoxResultFuture; + +use crate::queue::TaskQueue; +use crate::store::TaskStore; +use crate::{Task, TaskContent, TaskId, TaskState}; + pub struct TaskDispatcher { queue: TaskQueue, store: TaskStore, @@ -122,6 +122,9 @@ impl TaskDispatcher { } } + pub fn clear_task(&mut self) { + self.store.clear(); + } pub fn next_task_id(&self) -> TaskId { self.store.next_task_id() } diff --git a/frontend/rust-lib/flowy-user-deps/Cargo.toml b/frontend/rust-lib/flowy-user-deps/Cargo.toml index 82dac5eda898..3ab00eb697ce 100644 --- a/frontend/rust-lib/flowy-user-deps/Cargo.toml +++ b/frontend/rust-lib/flowy-user-deps/Cargo.toml @@ -8,11 +8,11 @@ edition = "2021" [dependencies] lib-infra = { path = "../../../shared-lib/lib-infra" } flowy-error = { workspace = true } -uuid = { version = "1.3.3", features = ["v4"] } -serde = { version = "1.0", features = ["derive"] } +uuid.workspace = true +serde.workspace = true collab-entity = { version = "0.1.0" } -serde_json = { version = "1.0"} -serde_repr = "0.1" -chrono = { version = "0.4.31", default-features = false, features = ["clock", "serde"] } -anyhow = "1.0.71" -tokio = { version = "1.26", features = ["sync"] } +serde_json.workspace = true +serde_repr.workspace = true +chrono = { workspace = true, default-features = false, features = ["clock", "serde"] } +anyhow.workspace = true +tokio = { workspace = true, features = ["sync"] } diff --git a/frontend/rust-lib/flowy-user-deps/src/cloud.rs b/frontend/rust-lib/flowy-user-deps/src/cloud.rs index 2b2b8635641d..bfa800acdd6a 100644 --- a/frontend/rust-lib/flowy-user-deps/src/cloud.rs +++ b/frontend/rust-lib/flowy-user-deps/src/cloud.rs @@ -73,6 +73,7 @@ pub trait UserCloudService: Send + Sync + 'static { fn sign_out(&self, token: Option<String>) -> FutureResult<(), Error>; /// Generate a sign in url for the user with the given email + /// Currently, only use the admin client for testing fn generate_sign_in_url_with_email(&self, email: &str) -> FutureResult<String, Error>; /// When the user opens the OAuth URL, it redirects to the corresponding provider's OAuth web page. @@ -93,8 +94,10 @@ pub trait UserCloudService: Send + Sync + 'static { /// return None if the user is not found fn get_user_profile(&self, credential: UserCredentials) -> FutureResult<UserProfile, FlowyError>; + fn open_workspace(&self, workspace_id: &str) -> FutureResult<UserWorkspace, FlowyError>; + /// Return the all the workspaces of the user - fn get_all_user_workspaces(&self, uid: i64) -> FutureResult<Vec<UserWorkspace>, Error>; + fn get_all_workspace(&self, uid: i64) -> FutureResult<Vec<UserWorkspace>, Error>; fn add_workspace_member( &self, @@ -145,13 +148,13 @@ pub trait UserCloudService: Send + Sync + 'static { ) -> FutureResult<(), Error>; } -pub type UserUpdateReceiver = tokio::sync::broadcast::Receiver<UserUpdate>; -pub type UserUpdateSender = tokio::sync::broadcast::Sender<UserUpdate>; +pub type UserUpdateReceiver = tokio::sync::mpsc::Receiver<UserUpdate>; +pub type UserUpdateSender = tokio::sync::mpsc::Sender<UserUpdate>; #[derive(Debug, Clone)] pub struct UserUpdate { pub uid: i64, - pub name: String, - pub email: String, + pub name: Option<String>, + pub email: Option<String>, pub encryption_sign: String, } diff --git a/frontend/rust-lib/flowy-user-deps/src/entities.rs b/frontend/rust-lib/flowy-user-deps/src/entities.rs index beda5f548c35..599dc1591b51 100644 --- a/frontend/rust-lib/flowy-user-deps/src/entities.rs +++ b/frontend/rust-lib/flowy-user-deps/src/entities.rs @@ -29,7 +29,7 @@ pub struct SignInParams { pub email: String, pub password: String, pub name: String, - pub auth_type: AuthType, + pub auth_type: Authenticator, pub device_id: String, } @@ -38,7 +38,7 @@ pub struct SignUpParams { pub email: String, pub name: String, pub password: String, - pub auth_type: AuthType, + pub auth_type: Authenticator, pub device_id: String, } @@ -101,7 +101,7 @@ impl UserAuthResponse for AuthResponse { #[derive(Clone, Debug)] pub struct UserCredentials { - /// Currently, the token is only used when the [AuthType] is AFCloud + /// Currently, the token is only used when the [Authenticator] is AFCloud pub token: Option<String>, /// The user id @@ -138,7 +138,7 @@ pub struct UserWorkspace { pub id: String, pub name: String, pub created_at: DateTime<Utc>, - /// The database storage id is used indexing all the database in current workspace. + /// The database storage id is used indexing all the database views in current workspace. #[serde(rename = "database_storage_id")] pub database_views_aggregate_id: String, } @@ -165,7 +165,7 @@ pub struct UserProfile { pub openai_key: String, pub stability_ai_key: String, pub workspace_id: String, - pub auth_type: AuthType, + pub authenticator: Authenticator, // If the encryption_sign is not empty, which means the user has enabled the encryption. pub encryption_type: EncryptionType, pub updated_at: i64, @@ -210,11 +210,11 @@ impl FromStr for EncryptionType { } } -impl<T> From<(&T, &AuthType)> for UserProfile +impl<T> From<(&T, &Authenticator)> for UserProfile where T: UserAuthResponse, { - fn from(params: (&T, &AuthType)) -> Self { + fn from(params: (&T, &Authenticator)) -> Self { let (value, auth_type) = params; let (icon_url, openai_key, stability_ai_key) = { value @@ -243,7 +243,7 @@ where icon_url, openai_key, workspace_id: value.latest_workspace().id.to_owned(), - auth_type: auth_type.clone(), + authenticator: auth_type.clone(), encryption_type: value.encryption_type(), stability_ai_key, updated_at: value.updated_at(), @@ -329,7 +329,7 @@ impl UpdateUserProfileParams { #[derive(Debug, Clone, Hash, Serialize_repr, Deserialize_repr, Eq, PartialEq)] #[repr(u8)] -pub enum AuthType { +pub enum Authenticator { /// It's a local server, we do fake sign in default. Local = 0, /// Currently not supported. It will be supported in the future when the @@ -339,25 +339,25 @@ pub enum AuthType { Supabase = 2, } -impl Default for AuthType { +impl Default for Authenticator { fn default() -> Self { Self::Local } } -impl AuthType { +impl Authenticator { pub fn is_local(&self) -> bool { - matches!(self, AuthType::Local) + matches!(self, Authenticator::Local) } } -impl From<i32> for AuthType { +impl From<i32> for Authenticator { fn from(value: i32) -> Self { match value { - 0 => AuthType::Local, - 1 => AuthType::AFCloud, - 2 => AuthType::Supabase, - _ => AuthType::Local, + 0 => Authenticator::Local, + 1 => Authenticator::AFCloud, + 2 => Authenticator::Supabase, + _ => Authenticator::Local, } } } diff --git a/frontend/rust-lib/flowy-user/Cargo.toml b/frontend/rust-lib/flowy-user/Cargo.toml index 459e6c4b54f8..dce4628ee1ae 100644 --- a/frontend/rust-lib/flowy-user/Cargo.toml +++ b/frontend/rust-lib/flowy-user/Cargo.toml @@ -23,28 +23,26 @@ collab-database = { version = "0.1.0" } collab-user = { version = "0.1.0" } collab-entity = { version = "0.1.0" } flowy-user-deps = { workspace = true } -anyhow = "1.0.75" - -tracing = { version = "0.1", features = ["log"] } -bytes = "1.4" -serde = { version = "1.0", features = ["derive"] } -serde_json = { version = "1.0" } -serde_repr = "0.1" -log = "0.4.17" -protobuf = { version = "2.28.0" } +anyhow.workspace = true +tracing.workspace = true +bytes.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_repr.workspace = true +protobuf.workspace = true lazy_static = "1.4.0" -diesel = { version = "1.4.8", features = ["sqlite"] } +diesel.workspace = true diesel_derives = { version = "1.4.1", features = ["sqlite"] } once_cell = "1.17.1" -parking_lot = "0.12.1" +parking_lot.workspace = true strum = "0.25" strum_macros = "0.25.2" -tokio = { version = "1.26", features = ["rt"] } +tokio = { workspace = true, features = ["rt"] } validator = "0.16.0" unicode-segmentation = "1.10" fancy-regex = "0.11.0" -uuid = { version = "1.3.3", features = [ "v4"] } -chrono = { version = "0.4.31", default-features = false, features = ["clock"] } +uuid.workspace = true +chrono = { workspace = true, default-features = false, features = ["clock"] } base64 = "^0.21" tokio-stream = "0.1.14" diff --git a/frontend/rust-lib/flowy-user/src/migrations/migrate_to_new_user.rs b/frontend/rust-lib/flowy-user/src/anon_user_upgrade/anon_user_data.rs similarity index 91% rename from frontend/rust-lib/flowy-user/src/migrations/migrate_to_new_user.rs rename to frontend/rust-lib/flowy-user/src/anon_user_upgrade/anon_user_data.rs index e9cb39ef2d4a..e895efc41343 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/migrate_to_new_user.rs +++ b/frontend/rust-lib/flowy-user/src/anon_user_upgrade/anon_user_data.rs @@ -11,7 +11,7 @@ use collab_database::database::{ }; use collab_database::rows::{database_row_document_id_from_row_id, mut_row_with_collab, RowId}; use collab_database::user::DatabaseWithViewsArray; -use collab_folder::core::Folder; +use collab_folder::{Folder, UserId}; use parking_lot::{Mutex, RwLock}; use collab_integrate::{PersistenceError, RocksCollabDB, YrsDocAction}; @@ -22,7 +22,7 @@ use crate::migrations::MigrationUser; /// Migration the collab objects of the old user to new user. Currently, it only happens when /// the user is a local user and try to use AppFlowy cloud service. -pub fn migration_local_user_on_sign_up( +pub fn migration_anon_user_on_sign_up( old_user: &MigrationUser, old_collab_db: &Arc<RocksCollabDB>, new_user: &MigrationUser, @@ -32,7 +32,6 @@ pub fn migration_local_user_on_sign_up( .with_write_txn(|new_collab_w_txn| { let old_collab_r_txn = old_collab_db.read_txn(); let old_to_new_id_map = Arc::new(Mutex::new(OldToNewIdMap::new())); - migrate_user_awareness(old_to_new_id_map.lock().deref_mut(), old_user, new_user)?; migrate_database_with_views_object( @@ -207,37 +206,31 @@ where old_folder_collab.with_origin_transact_mut(|txn| { old_collab_r_txn.load_doc_with_txn(old_uid, old_workspace_id, txn) })?; - let old_folder = Folder::open(Arc::new(MutexCollab::from_collab(old_folder_collab)), None); + let oid_user_id = UserId::from(old_uid); + let old_folder = Folder::open( + oid_user_id, + Arc::new(MutexCollab::from_collab(old_folder_collab)), + None, + ) + .map_err(|err| PersistenceError::InvalidData(err.to_string()))?; let mut folder_data = old_folder .get_folder_data() - .ok_or(PersistenceError::Internal( - anyhow!("Can't migrate the folder data").into(), - ))?; + .ok_or(PersistenceError::Internal(anyhow!( + "Can't migrate the folder data" + )))?; old_to_new_id_map .0 .insert(old_workspace_id.to_string(), new_workspace_id.to_string()); // 1. Replace the workspace views id to new id - debug_assert!(folder_data.workspaces.len() == 1); - + folder_data.workspace.id = new_workspace_id.clone(); folder_data - .workspaces + .workspace + .child_views .iter_mut() - .enumerate() - .for_each(|(index, workspace)| { - if index == 0 { - workspace.id = new_workspace_id.to_string(); - } else { - tracing::warn!("🔴migrate folder: more than one workspace"); - workspace.id = old_to_new_id_map.get_new_id(&workspace.id); - } - workspace - .child_views - .iter_mut() - .for_each(|view_identifier| { - view_identifier.id = old_to_new_id_map.get_new_id(&view_identifier.id); - }); + .for_each(|view_identifier| { + view_identifier.id = old_to_new_id_map.get_new_id(&view_identifier.id); }); folder_data.views.iter_mut().for_each(|view| { @@ -253,15 +246,6 @@ where }); }); - match old_to_new_id_map.get(&folder_data.current_workspace_id) { - Some(new_workspace_id) => { - folder_data.current_workspace_id = new_workspace_id.clone(); - }, - None => { - tracing::error!("🔴migrate folder: current workspace id not found"); - }, - } - match old_to_new_id_map.get(&folder_data.current_view) { Some(new_view_id) => { folder_data.current_view = new_view_id.clone(); @@ -274,9 +258,10 @@ where let origin = CollabOrigin::Client(CollabClient::new(new_uid, "phantom")); let new_folder_collab = Collab::new_with_raw_data(origin, new_workspace_id, vec![], vec![]) - .map_err(|err| PersistenceError::Internal(Box::new(err)))?; + .map_err(|err| PersistenceError::Internal(err.into()))?; let mutex_collab = Arc::new(MutexCollab::from_collab(new_folder_collab)); - let _ = Folder::create(mutex_collab.clone(), None, Some(folder_data)); + let new_user_id = UserId::from(new_uid); + let _ = Folder::create(new_user_id, mutex_collab.clone(), None, folder_data); { let mutex_collab = mutex_collab.lock(); diff --git a/frontend/rust-lib/flowy-user/src/anon_user_upgrade/mod.rs b/frontend/rust-lib/flowy-user/src/anon_user_upgrade/mod.rs new file mode 100644 index 000000000000..f48ee7a701d9 --- /dev/null +++ b/frontend/rust-lib/flowy-user/src/anon_user_upgrade/mod.rs @@ -0,0 +1,5 @@ +pub use anon_user_data::*; +pub use sync_new_user::*; + +mod anon_user_data; +mod sync_new_user; diff --git a/frontend/rust-lib/flowy-user/src/migrations/sync_new_user.rs b/frontend/rust-lib/flowy-user/src/anon_user_upgrade/sync_new_user.rs similarity index 86% rename from frontend/rust-lib/flowy-user/src/migrations/sync_new_user.rs rename to frontend/rust-lib/flowy-user/src/anon_user_upgrade/sync_new_user.rs index b30ece016042..1880693f2ad0 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/sync_new_user.rs +++ b/frontend/rust-lib/flowy-user/src/anon_user_upgrade/sync_new_user.rs @@ -10,7 +10,7 @@ use collab_database::database::get_database_row_ids; use collab_database::rows::database_row_document_id_from_row_id; use collab_database::user::{get_database_with_views, DatabaseWithViews}; use collab_entity::{CollabObject, CollabType}; -use collab_folder::core::{Folder, View, ViewLayout}; +use collab_folder::{Folder, View, ViewLayout}; use parking_lot::Mutex; use collab_integrate::{PersistenceError, RocksCollabDB, YrsDocAction}; @@ -67,6 +67,7 @@ pub async fn sync_user_data_to_cloud( tracing::error!("🔴sync {} failed: {:?}", view_id, err); } } + tokio::task::yield_now().await; Ok(()) } @@ -101,25 +102,26 @@ fn sync_views( match view.layout { ViewLayout::Document => { - let update = get_collab_init_update(uid, &collab_object, &collab_db)?; + let doc_state = get_collab_doc_state(uid, &collab_object, &collab_db)?; tracing::info!( "sync object: {} with update: {}", collab_object, - update.len() + doc_state.len() ); user_service - .create_collab_object(&collab_object, update) + .create_collab_object(&collab_object, doc_state) .await?; }, ViewLayout::Grid | ViewLayout::Board | ViewLayout::Calendar => { - let (database_update, row_ids) = get_database_init_update(uid, &collab_object, &collab_db)?; + let (database_doc_state, row_ids) = + get_database_doc_state(uid, &collab_object, &collab_db)?; tracing::info!( "sync object: {} with update: {}", collab_object, - database_update.len() + database_doc_state.len() ); user_service - .create_collab_object(&collab_object, database_update) + .create_collab_object(&collab_object, database_doc_state) .await?; // sync database's row @@ -134,16 +136,16 @@ fn sync_views( workspace_id.to_string(), device_id.clone(), ); - let database_row_update = - get_collab_init_update(uid, &database_row_collab_object, &collab_db)?; + let database_row_doc_state = + get_collab_doc_state(uid, &database_row_collab_object, &collab_db)?; tracing::info!( "sync object: {} with update: {}", database_row_collab_object, - database_row_update.len() + database_row_doc_state.len() ); let _ = user_service - .create_collab_object(&database_row_collab_object, database_row_update) + .create_collab_object(&database_row_collab_object, database_row_doc_state) .await; let database_row_document = CollabObject::new( @@ -154,16 +156,16 @@ fn sync_views( device_id.to_string(), ); // sync document in the row if exist - if let Ok(document_update) = - get_collab_init_update(uid, &database_row_document, &collab_db) + if let Ok(document_doc_state) = + get_collab_doc_state(uid, &database_row_document, &collab_db) { tracing::info!( "sync database row document: {} with update: {}", database_row_document, - document_update.len() + document_doc_state.len() ); let _ = user_service - .create_collab_object(&database_row_document, document_update) + .create_collab_object(&database_row_document, document_doc_state) .await; } } @@ -197,7 +199,7 @@ fn sync_views( }) } -fn get_collab_init_update( +fn get_collab_doc_state( uid: i64, collab_object: &CollabObject, collab_db: &Arc<RocksCollabDB>, @@ -208,15 +210,15 @@ fn get_collab_init_update( .read_txn() .load_doc_with_txn(uid, &collab_object.object_id, txn) })?; - let update = collab.encode_as_update_v1().0; - if update.is_empty() { + let doc_state = collab.encode_collab_v1().doc_state; + if doc_state.is_empty() { return Err(PersistenceError::UnexpectedEmptyUpdates); } - Ok(update) + Ok(doc_state.to_vec()) } -fn get_database_init_update( +fn get_database_doc_state( uid: i64, collab_object: &CollabObject, collab_db: &Arc<RocksCollabDB>, @@ -229,12 +231,12 @@ fn get_database_init_update( })?; let row_ids = get_database_row_ids(&collab).unwrap_or_default(); - let update = collab.encode_as_update_v1().0; - if update.is_empty() { + let doc_state = collab.encode_collab_v1().doc_state; + if doc_state.is_empty() { return Err(PersistenceError::UnexpectedEmptyUpdates); } - Ok((update, row_ids)) + Ok((doc_state.to_vec(), row_ids)) } async fn sync_folder( @@ -252,13 +254,14 @@ async fn sync_folder( .read_txn() .load_doc_with_txn(uid, workspace_id, txn) })?; - let update = collab.encode_as_update_v1().0; + let doc_state = collab.encode_collab_v1().doc_state; ( MutexFolder::new(Folder::open( + uid, Arc::new(MutexCollab::from_collab(collab)), None, - )), - update, + )?), + doc_state, ) }; @@ -275,7 +278,7 @@ async fn sync_folder( update.len() ); if let Err(err) = user_service - .create_collab_object(&collab_object, update) + .create_collab_object(&collab_object, update.to_vec()) .await { tracing::error!("🔴sync folder failed: {:?}", err); @@ -312,14 +315,14 @@ async fn sync_database_views( .map(|_| { ( get_database_with_views(&collab), - collab.encode_as_update_v1().0, + collab.encode_collab_v1().doc_state, ) }) }; - if let Ok((records, update)) = result { + if let Ok((records, doc_state)) = result { let _ = user_service - .create_collab_object(&collab_object, update) + .create_collab_object(&collab_object, doc_state.to_vec()) .await; records.into_iter().map(Arc::new).collect() } else { diff --git a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs index 2ee46aaaa2a6..4d6119eabd63 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs @@ -1,9 +1,12 @@ use std::convert::TryInto; +use validator::Validate; + use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_user_deps::entities::*; use crate::entities::parser::{UserEmail, UserIcon, UserName, UserOpenaiKey, UserPassword}; +use crate::entities::required_not_empty_str; use crate::entities::AuthTypePB; use crate::errors::ErrorCode; use crate::services::entities::HistoricalUser; @@ -83,7 +86,7 @@ impl std::convert::From<UserProfile> for UserProfilePB { token: user_profile.token, icon_url: user_profile.icon_url, openai_key: user_profile.openai_key, - auth_type: user_profile.auth_type.into(), + auth_type: user_profile.authenticator.into(), encryption_sign, encryption_type: encryption_ty, workspace_id: user_profile.workspace_id, @@ -217,10 +220,11 @@ impl From<Vec<UserWorkspace>> for RepeatedUserWorkspacePB { } } -#[derive(ProtoBuf, Default, Debug, Clone)] +#[derive(ProtoBuf, Default, Debug, Clone, Validate)] pub struct UserWorkspacePB { #[pb(index = 1)] - pub id: String, + #[validate(custom = "required_not_empty_str")] + pub workspace_id: String, #[pb(index = 2)] pub name: String, @@ -229,7 +233,7 @@ pub struct UserWorkspacePB { impl From<UserWorkspace> for UserWorkspacePB { fn from(value: UserWorkspace) -> Self { Self { - id: value.id, + workspace_id: value.id, name: value.name, } } diff --git a/frontend/rust-lib/flowy-user/src/entities/workspace_member.rs b/frontend/rust-lib/flowy-user/src/entities/workspace_member.rs index d94848c58841..a7a368f6e64b 100644 --- a/frontend/rust-lib/flowy-user/src/entities/workspace_member.rs +++ b/frontend/rust-lib/flowy-user/src/entities/workspace_member.rs @@ -103,3 +103,10 @@ impl From<Role> for AFRolePB { } } } + +#[derive(ProtoBuf, Default, Clone, Validate)] +pub struct UserWorkspaceIdPB { + #[pb(index = 1)] + #[validate(custom = "required_not_empty_str")] + pub workspace_id: String, +} diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index e83320b219f6..c9bc5fb10d26 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -2,7 +2,6 @@ use std::sync::Weak; use std::{convert::TryInto, sync::Arc}; use serde_json::Value; -use tracing::event; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_sqlite::kv::StorePreferences; @@ -93,7 +92,7 @@ pub async fn get_user_profile_handler( let cloned_user_profile = user_profile.clone(); // Refresh the user profile in the background - tokio::spawn(async move { + af_spawn(async move { if let Some(manager) = weak_manager.upgrade() { let _ = manager.refresh_user_profile(&cloned_user_profile).await; } @@ -101,21 +100,15 @@ pub async fn get_user_profile_handler( // When the user is logged in with a local account, the email field is a placeholder and should // not be exposed to the client. So we set the email field to an empty string. - if user_profile.auth_type == AuthType::Local { + if user_profile.authenticator == Authenticator::Local { user_profile.email = "".to_string(); } - event!( - tracing::Level::DEBUG, - "Get user profile: {:?}", - user_profile - ); - data_result_ok(user_profile.into()) } #[tracing::instrument(level = "debug", skip(manager))] -pub async fn sign_out(manager: AFPluginState<Weak<UserManager>>) -> Result<(), FlowyError> { +pub async fn sign_out_handler(manager: AFPluginState<Weak<UserManager>>) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; manager.sign_out().await?; Ok(()) @@ -264,7 +257,7 @@ pub async fn oauth_handler( ) -> DataResult<UserProfilePB, FlowyError> { let manager = upgrade_manager(manager)?; let params = data.into_inner(); - let auth_type: AuthType = params.auth_type.into(); + let auth_type: Authenticator = params.auth_type.into(); let user_profile = manager.sign_up(auth_type, BoxAny::new(params.map)).await?; data_result_ok(user_profile.into()) } @@ -276,7 +269,7 @@ pub async fn get_sign_in_url_handler( ) -> DataResult<SignInUrlPB, FlowyError> { let manager = upgrade_manager(manager)?; let params = data.into_inner(); - let auth_type: AuthType = params.auth_type.into(); + let auth_type: Authenticator = params.auth_type.into(); let sign_in_url = manager .generate_sign_in_url_with_email(&auth_type, ¶ms.email) .await?; @@ -425,7 +418,7 @@ pub async fn get_cloud_config_handler( } #[tracing::instrument(level = "debug", skip(manager), err)] -pub async fn get_all_user_workspace_handler( +pub async fn get_all_workspace_handler( manager: AFPluginState<Weak<UserManager>>, ) -> DataResult<RepeatedUserWorkspacePB, FlowyError> { let manager = upgrade_manager(manager)?; @@ -436,12 +429,12 @@ pub async fn get_all_user_workspace_handler( #[tracing::instrument(level = "debug", skip(data, manager), err)] pub async fn open_workspace_handler( - data: AFPluginData<UserWorkspacePB>, + data: AFPluginData<UserWorkspaceIdPB>, manager: AFPluginState<Weak<UserManager>>, ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; - let params = data.into_inner(); - manager.open_workspace(¶ms.id).await?; + let params = data.validate()?.into_inner(); + manager.open_workspace(¶ms.workspace_id).await?; Ok(()) } @@ -476,7 +469,7 @@ pub async fn open_historical_users_handler( ) -> Result<(), FlowyError> { let user = user.into_inner(); let manager = upgrade_manager(manager)?; - let auth_type = AuthType::from(user.auth_type); + let auth_type = Authenticator::from(user.auth_type); manager .open_historical_user(user.user_id, user.device_id, auth_type) .await?; diff --git a/frontend/rust-lib/flowy-user/src/event_map.rs b/frontend/rust-lib/flowy-user/src/event_map.rs index 93bca6403075..88a8decd29a7 100644 --- a/frontend/rust-lib/flowy-user/src/event_map.rs +++ b/frontend/rust-lib/flowy-user/src/event_map.rs @@ -1,7 +1,7 @@ use std::sync::{Arc, Weak}; use collab_database::database::WatchStream; -use collab_folder::core::FolderData; +use collab_folder::FolderData; use strum_macros::Display; use flowy_derive::{Flowy_Event, ProtoBuf_Enum}; @@ -29,7 +29,7 @@ pub fn init(user_session: Weak<UserManager>) -> AFPlugin { .event(UserEvent::SignUp, sign_up) .event(UserEvent::InitUser, init_user_handler) .event(UserEvent::GetUserProfile, get_user_profile_handler) - .event(UserEvent::SignOut, sign_out) + .event(UserEvent::SignOut, sign_out_handler) .event(UserEvent::UpdateUserProfile, update_user_profile_handler) .event(UserEvent::SetAppearanceSetting, set_appearance_setting) .event(UserEvent::GetAppearanceSetting, get_appearance_setting) @@ -41,7 +41,7 @@ pub fn init(user_session: Weak<UserManager>) -> AFPlugin { .event(UserEvent::OauthSignIn, oauth_handler) .event(UserEvent::GetSignInURL, get_sign_in_url_handler) .event(UserEvent::GetOauthURLWithProvider, sign_in_with_provider_handler) - .event(UserEvent::GetAllUserWorkspaces, get_all_user_workspace_handler) + .event(UserEvent::GetAllWorkspace, get_all_workspace_handler) .event(UserEvent::OpenWorkspace, open_workspace_handler) .event(UserEvent::UpdateNetworkState, update_network_state_handler) .event(UserEvent::GetHistoricalUsers, get_historical_users_handler) @@ -60,18 +60,18 @@ pub fn init(user_session: Weak<UserManager>) -> AFPlugin { .event(UserEvent::AddWorkspaceMember, add_workspace_member_handler) .event(UserEvent::RemoveWorkspaceMember, delete_workspace_member_handler) .event(UserEvent::GetWorkspaceMember, get_workspace_member_handler) - .event(UserEvent::UpdateWorkspaceMember, update_workspace_member_handler,) + .event(UserEvent::UpdateWorkspaceMember, update_workspace_member_handler) } #[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] #[event_err = "FlowyError"] pub enum UserEvent { - /// Only use when the [AuthType] is Local or SelfHosted + /// Only use when the [Authenticator] is Local or SelfHosted /// Logging into an account using a register email and password #[event(input = "SignInPayloadPB", output = "UserProfilePB")] SignIn = 0, - /// Only use when the [AuthType] is Local or SelfHosted + /// Only use when the [Authenticator] is Local or SelfHosted /// Creating a new account #[event(input = "SignUpPayloadPB", output = "UserProfilePB")] SignUp = 1, @@ -109,7 +109,7 @@ pub enum UserEvent { OauthSignIn = 10, /// Get the OAuth callback url - /// Only use when the [AuthType] is AFCloud + /// Only use when the [Authenticator] is AFCloud #[event(input = "SignInUrlPayloadPB", output = "SignInUrlPB")] GetSignInURL = 11, @@ -129,10 +129,10 @@ pub enum UserEvent { CheckEncryptionSign = 16, /// Return the all the workspaces of the user - #[event()] - GetAllUserWorkspaces = 20, + #[event(output = "RepeatedUserWorkspacePB")] + GetAllWorkspace = 17, - #[event(input = "UserWorkspacePB")] + #[event(input = "UserWorkspaceIdPB")] OpenWorkspace = 21, #[event(input = "NetworkStatePB")] @@ -145,7 +145,7 @@ pub enum UserEvent { OpenHistoricalUser = 26, /// Push a realtime event to the user. Currently, the realtime event - /// is only used when the auth type is: [AuthType::Supabase]. + /// is only used when the auth type is: [Authenticator::Supabase]. /// #[event(input = "RealtimePayloadPB")] PushRealtimeEvent = 27, @@ -201,9 +201,9 @@ pub struct SignUpContext { } pub trait UserStatusCallback: Send + Sync + 'static { - /// When the [AuthType] changed, this method will be called. Currently, the auth type + /// When the [Authenticator] changed, this method will be called. Currently, the auth type /// will be changed when the user sign in or sign up. - fn auth_type_did_changed(&self, _auth_type: AuthType) {} + fn authenticator_did_changed(&self, _authenticator: Authenticator) {} /// This will be called after the application launches if the user is already signed in. /// If the user is not signed in, this method will not be called fn did_init( @@ -244,7 +244,8 @@ pub trait UserCloudServiceProvider: Send + Sync + 'static { fn set_enable_sync(&self, uid: i64, enable_sync: bool); fn set_encrypt_secret(&self, secret: String); - fn set_auth_type(&self, auth_type: AuthType); + fn set_authenticator(&self, authenticator: Authenticator); + fn get_authenticator(&self) -> Authenticator; fn set_device_id(&self, device_id: &str); fn get_user_service(&self) -> Result<Arc<dyn UserCloudService>, FlowyError>; fn service_name(&self) -> String; @@ -266,8 +267,12 @@ where (**self).set_encrypt_secret(secret) } - fn set_auth_type(&self, auth_type: AuthType) { - (**self).set_auth_type(auth_type) + fn set_authenticator(&self, authenticator: Authenticator) { + (**self).set_authenticator(authenticator) + } + + fn get_authenticator(&self) -> Authenticator { + (**self).get_authenticator() } fn set_device_id(&self, device_id: &str) { diff --git a/frontend/rust-lib/flowy-user/src/lib.rs b/frontend/rust-lib/flowy-user/src/lib.rs index 2a27658200f3..982b7af837a9 100644 --- a/frontend/rust-lib/flowy-user/src/lib.rs +++ b/frontend/rust-lib/flowy-user/src/lib.rs @@ -1,6 +1,7 @@ #[macro_use] extern crate flowy_sqlite; +mod anon_user_upgrade; pub mod entities; mod event_handler; pub mod event_map; diff --git a/frontend/rust-lib/flowy-user/src/manager.rs b/frontend/rust-lib/flowy-user/src/manager.rs index 4fb483e6e9a9..b9bc0352a593 100644 --- a/frontend/rust-lib/flowy-user/src/manager.rs +++ b/frontend/rust-lib/flowy-user/src/manager.rs @@ -1,6 +1,11 @@ +use std::path::PathBuf; use std::string::ToString; +use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::{Arc, Weak}; +use base64::alphabet::URL_SAFE; +use base64::engine::general_purpose::PAD; +use base64::engine::GeneralPurpose; use collab_user::core::MutexUserAwareness; use serde_json::Value; use tokio::sync::{Mutex, RwLock}; @@ -16,27 +21,28 @@ use flowy_sqlite::ConnectionPool; use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; use flowy_user_deps::cloud::UserUpdate; use flowy_user_deps::entities::*; +use lib_dispatch::prelude::af_spawn; use lib_infra::box_any::BoxAny; +use crate::anon_user_upgrade::{migration_anon_user_on_sign_up, sync_user_data_to_cloud}; use crate::entities::{AuthStateChangedPB, AuthStatePB, UserProfilePB, UserSettingPB}; use crate::event_map::{DefaultUserStatusCallback, UserCloudServiceProvider, UserStatusCallback}; -use crate::migrations::historical_document::HistoricalEmptyDocumentMigration; -use crate::migrations::migrate_to_new_user::migration_local_user_on_sign_up; -use crate::migrations::migration::UserLocalDataMigration; -use crate::migrations::sync_new_user::sync_user_data_to_cloud; +use crate::migrations::document_empty_content::HistoricalEmptyDocumentMigration; +use crate::migrations::migration::{UserDataMigration, UserLocalDataMigration}; +use crate::migrations::workspace_and_favorite_v1::FavoriteV1AndWorkspaceArrayMigration; use crate::migrations::MigrationUser; use crate::services::cloud_config::get_cloud_config; use crate::services::collab_interact::{CollabInteract, DefaultCollabInteract}; -use crate::services::database::UserDB; +use crate::services::database::{UserDB, UserDBPath}; use crate::services::entities::{ResumableSignUp, Session}; use crate::services::user_awareness::UserAwarenessDataSource; use crate::services::user_sql::{UserTable, UserTableChangeset}; use crate::services::user_workspace::save_user_workspaces; use crate::{errors::FlowyError, notification::*}; +pub const URL_SAFE_ENGINE: GeneralPurpose = GeneralPurpose::new(&URL_SAFE, PAD); pub struct UserSessionConfig { root_dir: String, - /// Used as the key of `Session` when saving session information to KV. session_cache_key: String, } @@ -54,7 +60,8 @@ impl UserSessionConfig { } pub struct UserManager { - database: UserDB, + database: Arc<UserDB>, + user_paths: UserPaths, session_config: UserSessionConfig, pub(crate) cloud_services: Arc<dyn UserCloudServiceProvider>, pub(crate) store_preferences: Arc<StorePreferences>, @@ -63,7 +70,8 @@ pub struct UserManager { pub(crate) collab_builder: Weak<AppFlowyCollabBuilder>, pub(crate) collab_interact: RwLock<Arc<dyn CollabInteract>>, resumable_sign_up: Mutex<Option<ResumableSignUp>>, - current_session: parking_lot::RwLock<Option<Session>>, + current_session: Arc<parking_lot::RwLock<Option<Session>>>, + refresh_user_profile_since: AtomicI64, } impl UserManager { @@ -73,12 +81,17 @@ impl UserManager { store_preferences: Arc<StorePreferences>, collab_builder: Weak<AppFlowyCollabBuilder>, ) -> Arc<Self> { - let database = UserDB::new(&session_config.root_dir); + let user_paths = UserPaths { + root: session_config.root_dir.clone(), + }; + let database = Arc::new(UserDB::new(user_paths.clone())); let user_status_callback: RwLock<Arc<dyn UserStatusCallback>> = RwLock::new(Arc::new(DefaultUserStatusCallback)); + let refresh_user_profile_since = AtomicI64::new(0); let user_manager = Arc::new(Self { database, + user_paths, session_config, cloud_services, store_preferences, @@ -88,13 +101,14 @@ impl UserManager { collab_interact: RwLock::new(Arc::new(DefaultCollabInteract)), resumable_sign_up: Default::default(), current_session: Default::default(), + refresh_user_profile_since, }); let weak_user_manager = Arc::downgrade(&user_manager); if let Ok(user_service) = user_manager.cloud_services.get_user_service() { if let Some(mut rx) = user_service.subscribe_user_update() { - tokio::spawn(async move { - while let Ok(update) = rx.recv().await { + af_spawn(async move { + while let Some(update) = rx.recv().await { if let Some(user_manager) = weak_user_manager.upgrade() { if let Err(err) = user_manager.handler_user_update(update).await { error!("handler_user_update failed: {:?}", err); @@ -119,13 +133,28 @@ impl UserManager { /// a local data migration for the user. After ensuring the user's data is migrated and up-to-date, /// the function will set up the collaboration configuration and initialize the user's awareness. Upon successful /// completion, a user status callback is invoked to signify that the initialization process is complete. + #[instrument(level = "debug", skip_all, err)] pub async fn init<C: UserStatusCallback + 'static, I: CollabInteract>( &self, user_status_callback: C, collab_interact: I, ) -> Result<(), FlowyError> { + let user_status_callback = Arc::new(user_status_callback); + *self.user_status_callback.write().await = user_status_callback.clone(); + *self.collab_interact.write().await = Arc::new(collab_interact); + if let Ok(session) = self.get_session() { let user = self.get_user_profile(session.user_id).await?; + + event!( + tracing::Level::INFO, + "init user session: {}:{}", + user.uid, + user.email + ); + + // Set the token if the current cloud service using token to authenticate + // Currently, only the AppFlowy cloud using token to init the client api. if let Err(err) = self.cloud_services.set_token(&user.token) { error!("Set token failed: {}", err); } @@ -133,10 +162,13 @@ impl UserManager { // Subscribe the token state let weak_pool = Arc::downgrade(&self.db_pool(user.uid)?); if let Some(mut token_state_rx) = self.cloud_services.subscribe_token_state() { - tokio::spawn(async move { + event!(tracing::Level::DEBUG, "Listen token state change"); + af_spawn(async move { while let Some(token_state) = token_state_rx.next().await { + debug!("Token state changed: {:?}", token_state); match token_state { UserTokenState::Refresh { token } => { + // Only save the token if the token is different from the current token if token != user.token { if let Some(pool) = weak_pool.upgrade() { // Save the new token @@ -146,26 +178,26 @@ impl UserManager { } } }, - UserTokenState::Invalid => { - send_auth_state_notification(AuthStateChangedPB { - state: AuthStatePB::InvalidAuth, - message: "Token is invalid".to_string(), - }) - .send(); - }, + UserTokenState::Invalid => {}, } } }); } // Do the user data migration if needed + event!(tracing::Level::INFO, "Prepare user data migration"); match ( self.database.get_collab_db(session.user_id), self.database.get_pool(session.user_id), ) { (Ok(collab_db), Ok(sqlite_pool)) => { - match UserLocalDataMigration::new(session.clone(), collab_db, sqlite_pool) - .run(vec![Box::new(HistoricalEmptyDocumentMigration)]) + // ⚠️The order of migrations is crucial. If you're adding a new migration, please ensure + // it's appended to the end of the list. + let migrations: Vec<Box<dyn UserDataMigration>> = vec![ + Box::new(HistoricalEmptyDocumentMigration), + Box::new(FavoriteV1AndWorkspaceArrayMigration), + ]; + match UserLocalDataMigration::new(session.clone(), collab_db, sqlite_pool).run(migrations) { Ok(applied_migrations) => { if !applied_migrations.is_empty() { @@ -196,8 +228,6 @@ impl UserManager { error!("Failed to call did_init callback: {:?}", e); } } - *self.user_status_callback.write().await = Arc::new(user_status_callback); - *self.collab_interact.write().await = Arc::new(collab_interact); Ok(()) } @@ -228,9 +258,9 @@ impl UserManager { pub async fn sign_in( &self, params: BoxAny, - auth_type: AuthType, + authenticator: Authenticator, ) -> Result<UserProfile, FlowyError> { - self.update_auth_type(&auth_type).await; + self.update_authenticator(&authenticator).await; let response: AuthResponse = self .cloud_services .get_user_service()? @@ -240,8 +270,10 @@ impl UserManager { self.set_collab_config(&session); let latest_workspace = response.latest_workspace.clone(); - let user_profile = UserProfile::from((&response, &auth_type)); - self.save_auth_data(&response, &auth_type, &session).await?; + let user_profile = UserProfile::from((&response, &authenticator)); + self + .save_auth_data(&response, &authenticator, &session) + .await?; let _ = self .initialize_user_awareness(&session, UserAwarenessDataSource::Remote) .await; @@ -263,13 +295,13 @@ impl UserManager { Ok(user_profile) } - pub(crate) async fn update_auth_type(&self, auth_type: &AuthType) { + pub(crate) async fn update_authenticator(&self, authenticator: &Authenticator) { self .user_status_callback .read() .await - .auth_type_did_changed(auth_type.clone()); - self.cloud_services.set_auth_type(auth_type.clone()); + .authenticator_did_changed(authenticator.clone()); + self.cloud_services.set_authenticator(authenticator.clone()); } /// Manages the user sign-up process, potentially migrating data if necessary. @@ -282,15 +314,15 @@ impl UserManager { #[tracing::instrument(level = "info", skip(self, params))] pub async fn sign_up( &self, - auth_type: AuthType, + authenticator: Authenticator, params: BoxAny, ) -> Result<UserProfile, FlowyError> { - self.update_auth_type(&auth_type).await; + self.update_authenticator(&authenticator).await; - let migration_user = self.get_migration_user(&auth_type).await; + let migration_user = self.get_migration_user(&authenticator).await; let auth_service = self.cloud_services.get_user_service()?; let response: AuthResponse = auth_service.sign_up(params).await?; - let user_profile = UserProfile::from((&response, &auth_type)); + let user_profile = UserProfile::from((&response, &authenticator)); if user_profile.encryption_type.is_need_encrypt_secret() { self .resumable_sign_up @@ -300,11 +332,11 @@ impl UserManager { user_profile: user_profile.clone(), migration_user, response, - auth_type, + authenticator, }); } else { self - .continue_sign_up(&user_profile, migration_user, response, &auth_type) + .continue_sign_up(&user_profile, migration_user, response, &authenticator) .await?; } Ok(user_profile) @@ -316,7 +348,7 @@ impl UserManager { user_profile, migration_user, response, - auth_type, + authenticator, } = self .resumable_sign_up .lock() @@ -327,7 +359,7 @@ impl UserManager { "No resumable sign up data", ))?; self - .continue_sign_up(&user_profile, migration_user, response, &auth_type) + .continue_sign_up(&user_profile, migration_user, response, &authenticator) .await?; Ok(()) } @@ -338,7 +370,7 @@ impl UserManager { user_profile: &UserProfile, migration_user: Option<MigrationUser>, response: AuthResponse, - auth_type: &AuthType, + authenticator: &Authenticator, ) -> FlowyResult<()> { let new_session = Session::from(&response); self.set_collab_config(&new_session); @@ -357,12 +389,12 @@ impl UserManager { }; event!( tracing::Level::INFO, - "Migrate old user data from {:?} to {:?}", + "Migrate anon user data from {:?} to {:?}", old_user.user_profile.uid, new_user.user_profile.uid ); self - .migrate_local_user_to_cloud(&old_user, &new_user) + .migrate_anon_user_data_to_cloud(&old_user, &new_user) .await?; let _ = self.database.close(old_user.session.user_id); } @@ -372,8 +404,9 @@ impl UserManager { .await; self - .save_auth_data(&response, auth_type, &new_session) + .save_auth_data(&response, authenticator, &new_session) .await?; + self .user_status_callback .read() @@ -401,7 +434,7 @@ impl UserManager { self.set_session(None)?; let server = self.cloud_services.get_user_service()?; - tokio::spawn(async move { + af_spawn(async move { if let Err(err) = server.sign_out(None).await { event!(tracing::Level::ERROR, "{:?}", err); } @@ -439,14 +472,28 @@ impl UserManager { pub async fn get_user_profile(&self, uid: i64) -> Result<UserProfile, FlowyError> { let user: UserProfile = user_table::dsl::user_table .filter(user_table::id.eq(&uid.to_string())) - .first::<UserTable>(&*(self.db_connection(uid)?))? + .first::<UserTable>(&*(self.db_connection(uid)?)) + .map_err(|err| { + FlowyError::record_not_found().with_context(format!( + "Can't find the user profile for user id: {}, error: {:?}", + uid, err + )) + })? .into(); Ok(user) } - #[tracing::instrument(level = "info", skip_all)] + #[tracing::instrument(level = "info", skip_all, err)] pub async fn refresh_user_profile(&self, old_user_profile: &UserProfile) -> FlowyResult<()> { + let now = chrono::Utc::now().timestamp(); + + // Add debounce to avoid too many requests + if now - self.refresh_user_profile_since.load(Ordering::SeqCst) < 5 { + return Ok(()); + } + + self.refresh_user_profile_since.store(now, Ordering::SeqCst); let uid = old_user_profile.uid; let result: Result<UserProfile, FlowyError> = self .cloud_services @@ -459,14 +506,14 @@ impl UserManager { // If the authentication type has changed, it indicates that the user has signed in // using a different release package but is sharing the same data folder. // In such cases, notify the frontend to log out. - if old_user_profile.auth_type != AuthType::Local - && new_user_profile.auth_type != old_user_profile.auth_type + if old_user_profile.authenticator != Authenticator::Local + && new_user_profile.authenticator != old_user_profile.authenticator { event!( tracing::Level::INFO, - "User login with different cloud: {:?} -> {:?}", - old_user_profile.auth_type, - new_user_profile.auth_type + "User login with different authenticator: {:?} -> {:?}", + old_user_profile.authenticator, + new_user_profile.authenticator ); send_auth_state_notification(AuthStateChangedPB { @@ -479,7 +526,7 @@ impl UserManager { // If the user profile is updated, save the new user profile if new_user_profile.updated_at > old_user_profile.updated_at { - check_encryption_sign(old_user_profile, &new_user_profile.encryption_type.sign()); + validate_encryption_sign(old_user_profile, &new_user_profile.encryption_type.sign()); // Save the new user profile let changeset = UserTableChangeset::from_user_profile(new_user_profile); let _ = upsert_user_profile_change(uid, self.database.get_pool(uid)?, changeset); @@ -488,12 +535,12 @@ impl UserManager { }, Err(err) => { // If the user is not found, notify the frontend to logout - if err.is_record_not_found() { + if err.is_unauthorized() { event!( - tracing::Level::INFO, - "User is not found on the server when refreshing profile" + tracing::Level::ERROR, + "User is unauthorized, sign out the user" ); - + self.sign_out().await?; send_auth_state_notification(AuthStateChangedPB { state: AuthStatePB::InvalidAuth, message: "User is not found on the server".to_string(), @@ -505,8 +552,9 @@ impl UserManager { } } + #[instrument(level = "info", skip_all)] pub fn user_dir(&self, uid: i64) -> String { - format!("{}/{}", self.session_config.root_dir, uid) + self.user_paths.user_dir(uid) } pub fn user_setting(&self) -> Result<UserSettingPB, FlowyError> { @@ -536,7 +584,7 @@ impl UserManager { params: UpdateUserProfileParams, ) -> Result<(), FlowyError> { let server = self.cloud_services.get_user_service()?; - tokio::spawn(async move { + af_spawn(async move { let credentials = UserCredentials::new(Some(token), Some(uid), None); server.update_user(credentials, params).await }) @@ -610,10 +658,10 @@ impl UserManager { pub(crate) async fn generate_sign_in_url_with_email( &self, - auth_type: &AuthType, + authenticator: &Authenticator, email: &str, ) -> Result<String, FlowyError> { - self.update_auth_type(auth_type).await; + self.update_authenticator(authenticator).await; let auth_service = self.cloud_services.get_user_service()?; let url = auth_service @@ -627,7 +675,7 @@ impl UserManager { &self, oauth_provider: &str, ) -> Result<String, FlowyError> { - self.update_auth_type(&AuthType::AFCloud).await; + self.update_authenticator(&Authenticator::AFCloud).await; let auth_service = self.cloud_services.get_user_service()?; let url = auth_service .generate_oauth_url_with_provider(oauth_provider) @@ -635,27 +683,28 @@ impl UserManager { Ok(url) } + #[instrument(level = "info", skip_all, err)] async fn save_auth_data( &self, response: &impl UserAuthResponse, - auth_type: &AuthType, + authenticator: &Authenticator, session: &Session, ) -> Result<(), FlowyError> { - let user_profile = UserProfile::from((response, auth_type)); + let user_profile = UserProfile::from((response, authenticator)); let uid = user_profile.uid; event!(tracing::Level::DEBUG, "Save new history user: {:?}", uid); self.add_historical_user( uid, response.device_id(), response.user_name().to_string(), - auth_type, + authenticator, self.user_dir(uid), ); event!(tracing::Level::DEBUG, "Save new history user workspace"); save_user_workspaces(uid, self.db_pool(uid)?, response.user_workspaces())?; event!(tracing::Level::INFO, "Save new user profile to disk"); self - .save_user(uid, (user_profile, auth_type.clone()).into()) + .save_user(uid, (user_profile, authenticator.clone()).into()) .await?; self.set_session(Some(session.clone()))?; Ok(()) @@ -673,7 +722,7 @@ impl UserManager { if session.user_id == user_update.uid { debug!("Receive user update: {:?}", user_update); let user_profile = self.get_user_profile(user_update.uid).await?; - if !check_encryption_sign(&user_profile, &user_update.encryption_sign) { + if !validate_encryption_sign(&user_profile, &user_update.encryption_sign) { return Ok(()); } @@ -688,14 +737,14 @@ impl UserManager { Ok(()) } - async fn migrate_local_user_to_cloud( + async fn migrate_anon_user_data_to_cloud( &self, old_user: &MigrationUser, new_user: &MigrationUser, ) -> Result<(), FlowyError> { let old_collab_db = self.database.get_collab_db(old_user.session.user_id)?; let new_collab_db = self.database.get_collab_db(new_user.session.user_id)?; - migration_local_user_on_sign_up(old_user, &old_collab_db, new_user, &new_collab_db)?; + migration_anon_user_on_sign_up(old_user, &old_collab_db, new_user, &new_collab_db)?; if let Err(err) = sync_user_data_to_cloud( self.cloud_services.get_user_service()?, @@ -718,7 +767,7 @@ impl UserManager { } } -fn check_encryption_sign(user_profile: &UserProfile, encryption_sign: &str) -> bool { +fn validate_encryption_sign(user_profile: &UserProfile, encryption_sign: &str) -> bool { // If the local user profile's encryption sign is not equal to the user update's encryption sign, // which means the user enable encryption in another device, we should logout the current user. let is_valid = user_profile.encryption_type.sign() == encryption_sign; @@ -760,3 +809,26 @@ fn save_user_token(uid: i64, pool: Arc<ConnectionPool>, token: String) -> FlowyR let changeset = UserTableChangeset::new(params); upsert_user_profile_change(uid, pool, changeset) } + +#[derive(Clone)] +struct UserPaths { + root: String, +} + +impl UserPaths { + fn user_dir(&self, uid: i64) -> String { + format!("{}/{}", self.root, uid) + } +} + +impl UserDBPath for UserPaths { + fn user_db_path(&self, uid: i64) -> PathBuf { + PathBuf::from(self.user_dir(uid)) + } + + fn collab_db_path(&self, uid: i64) -> PathBuf { + let mut path = PathBuf::from(self.user_dir(uid)); + path.push("collab_db"); + path + } +} diff --git a/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs b/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs new file mode 100644 index 000000000000..27d05869d890 --- /dev/null +++ b/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs @@ -0,0 +1,69 @@ +use std::sync::Arc; + +use collab::core::collab::MutexCollab; +use collab::core::origin::{CollabClient, CollabOrigin}; +use collab_document::document::Document; +use collab_document::document_data::default_document_data; +use collab_folder::Folder; +use tracing::{event, instrument}; + +use collab_integrate::{RocksCollabDB, YrsDocAction}; +use flowy_error::{internal_error, FlowyResult}; + +use crate::migrations::migration::UserDataMigration; +use crate::migrations::util::load_collab; +use crate::services::entities::Session; + +/// Migrate the first level documents of the workspace by inserting documents +pub struct HistoricalEmptyDocumentMigration; + +impl UserDataMigration for HistoricalEmptyDocumentMigration { + fn name(&self) -> &str { + "historical_empty_document" + } + + #[instrument(name = "HistoricalEmptyDocumentMigration", skip_all, err)] + fn run(&self, session: &Session, collab_db: &Arc<RocksCollabDB>) -> FlowyResult<()> { + let write_txn = collab_db.write_txn(); + let origin = CollabOrigin::Client(CollabClient::new(session.user_id, "phantom")); + // Deserialize the folder from the raw data + if let Ok(folder_collab) = load_collab(session.user_id, &write_txn, &session.user_workspace.id) + { + let folder = Folder::open(session.user_id, folder_collab, None)?; + + // Migration the first level documents of the workspace. The first level documents do not have + // any updates. So when calling load_collab, it will return error. + let migration_views = folder.get_workspace_views(&session.user_workspace.id); + for view in migration_views { + if load_collab(session.user_id, &write_txn, &view.id).is_err() { + // Create a document with default data + let document_data = default_document_data(); + let collab = Arc::new(MutexCollab::new(origin.clone(), &view.id, vec![])); + if let Ok(document) = Document::create_with_data(collab.clone(), document_data) { + // Remove all old updates and then insert the new update + let encode = document.get_collab().encode_collab_v1(); + if let Err(err) = write_txn.flush_doc_with( + session.user_id, + &view.id, + &encode.doc_state, + &encode.state_vector, + ) { + event!( + tracing::Level::ERROR, + "Failed to migrate document {}, error: {}", + view.id, + err + ); + } else { + event!(tracing::Level::INFO, "Did migrate document {}", view.id); + } + } + } + } + } + + event!(tracing::Level::INFO, "Save all migrated documents"); + write_txn.commit_transaction().map_err(internal_error)?; + Ok(()) + } +} diff --git a/frontend/rust-lib/flowy-user/src/migrations/historical_document.rs b/frontend/rust-lib/flowy-user/src/migrations/historical_document.rs deleted file mode 100644 index 0b256141735e..000000000000 --- a/frontend/rust-lib/flowy-user/src/migrations/historical_document.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::sync::Arc; - -use collab::core::collab::MutexCollab; -use collab::core::origin::{CollabClient, CollabOrigin}; -use collab_document::document::Document; -use collab_document::document_data::default_document_data; -use collab_folder::core::Folder; - -use collab_integrate::{RocksCollabDB, YrsDocAction}; -use flowy_error::{internal_error, FlowyResult}; - -use crate::migrations::migration::UserDataMigration; -use crate::services::entities::Session; - -/// Migrate the first level documents of the workspace by inserting documents -pub struct HistoricalEmptyDocumentMigration; - -impl UserDataMigration for HistoricalEmptyDocumentMigration { - fn name(&self) -> &str { - "historical_empty_document" - } - - fn run(&self, session: &Session, collab_db: &Arc<RocksCollabDB>) -> FlowyResult<()> { - let write_txn = collab_db.write_txn(); - if let Ok(updates) = write_txn.get_all_updates(session.user_id, &session.user_workspace.id) { - let origin = CollabOrigin::Client(CollabClient::new(session.user_id, "phantom")); - // Deserialize the folder from the raw data - let folder = - Folder::from_collab_raw_data(origin.clone(), updates, &session.user_workspace.id, vec![])?; - - // Migration the first level documents of the workspace - let migration_views = folder.get_workspace_views(&session.user_workspace.id); - for view in migration_views { - // Read all updates of the view - if let Ok(view_updates) = write_txn.get_all_updates(session.user_id, &view.id) { - if Document::from_updates(origin.clone(), view_updates, &view.id, vec![]).is_err() { - // Create a document with default data - let document_data = default_document_data(); - let collab = Arc::new(MutexCollab::new(origin.clone(), &view.id, vec![])); - if let Ok(document) = Document::create_with_data(collab.clone(), document_data) { - // Remove all old updates and then insert the new update - let (doc_state, sv) = document.get_collab().encode_as_update_v1(); - write_txn - .flush_doc_with(session.user_id, &view.id, &doc_state, &sv) - .map_err(internal_error)?; - } - } - } - } - } - write_txn.commit_transaction().map_err(internal_error)?; - Ok(()) - } -} diff --git a/frontend/rust-lib/flowy-user/src/migrations/mod.rs b/frontend/rust-lib/flowy-user/src/migrations/mod.rs index b35e4377dc2d..4989032da651 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/mod.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/mod.rs @@ -1,7 +1,7 @@ pub use define::*; mod define; -pub mod historical_document; -pub mod migrate_to_new_user; +pub mod document_empty_content; pub mod migration; -pub mod sync_new_user; +mod util; +pub mod workspace_and_favorite_v1; diff --git a/frontend/rust-lib/flowy-user/src/migrations/util.rs b/frontend/rust-lib/flowy-user/src/migrations/util.rs new file mode 100644 index 000000000000..92f2c5ee4faa --- /dev/null +++ b/frontend/rust-lib/flowy-user/src/migrations/util.rs @@ -0,0 +1,23 @@ +use std::sync::Arc; + +use collab::core::collab::MutexCollab; +use collab::preclude::Collab; + +use collab_integrate::{PersistenceError, YrsDocAction}; +use flowy_error::{internal_error, FlowyResult}; + +pub fn load_collab<'a, R>( + uid: i64, + collab_r_txn: &R, + object_id: &str, +) -> FlowyResult<Arc<MutexCollab>> +where + R: YrsDocAction<'a>, + PersistenceError: From<R::Error>, +{ + let collab = Collab::new(uid, object_id, "phantom", vec![]); + collab + .with_origin_transact_mut(|txn| collab_r_txn.load_doc_with_txn(uid, &object_id, txn)) + .map_err(internal_error)?; + Ok(Arc::new(MutexCollab::from_collab(collab))) +} diff --git a/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs b/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs new file mode 100644 index 000000000000..8429e07b8520 --- /dev/null +++ b/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use collab_folder::Folder; +use tracing::instrument; + +use collab_integrate::{RocksCollabDB, YrsDocAction}; +use flowy_error::{internal_error, FlowyResult}; + +use crate::migrations::migration::UserDataMigration; +use crate::migrations::util::load_collab; +use crate::services::entities::Session; + +/// 1. Migrate the workspace: { favorite: [view_id] } to { favorite: { uid: [view_id] } } +/// 2. Migrate { workspaces: [workspace object] } to { views: { workspace object } }. Make each folder +/// only have one workspace. +pub struct FavoriteV1AndWorkspaceArrayMigration; + +impl UserDataMigration for FavoriteV1AndWorkspaceArrayMigration { + fn name(&self) -> &str { + "workspace_favorite_v1_and_workspace_array_migration" + } + + #[instrument(name = "FavoriteV1AndWorkspaceArrayMigration", skip_all, err)] + fn run(&self, session: &Session, collab_db: &Arc<RocksCollabDB>) -> FlowyResult<()> { + let write_txn = collab_db.write_txn(); + if let Ok(collab) = load_collab(session.user_id, &write_txn, &session.user_workspace.id) { + let folder = Folder::open(session.user_id, collab, None)?; + folder.migrate_workspace_to_view(); + + let favorite_view_ids = folder + .get_favorite_v1() + .into_iter() + .map(|fav| fav.id) + .collect::<Vec<String>>(); + + if !favorite_view_ids.is_empty() { + folder.add_favorites(favorite_view_ids); + } + + let encode = folder.encode_collab_v1(); + write_txn + .flush_doc_with( + session.user_id, + &session.user_workspace.id, + &encode.doc_state, + &encode.state_vector, + ) + .map_err(internal_error)?; + } + + write_txn.commit_transaction().map_err(internal_error)?; + Ok(()) + } +} diff --git a/frontend/rust-lib/flowy-user/src/services/database.rs b/frontend/rust-lib/flowy-user/src/services/database.rs index bb2f4cf7d44b..032433cd4cd6 100644 --- a/frontend/rust-lib/flowy-user/src/services/database.rs +++ b/frontend/rust-lib/flowy-user/src/services/database.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::{collections::HashMap, sync::Arc, time::Duration}; use lazy_static::lazy_static; @@ -18,14 +18,19 @@ use flowy_user_deps::entities::{UserProfile, UserWorkspace}; use crate::services::user_sql::UserTable; use crate::services::user_workspace_sql::UserWorkspaceTable; +pub trait UserDBPath: Send + Sync + 'static { + fn user_db_path(&self, uid: i64) -> PathBuf; + fn collab_db_path(&self, uid: i64) -> PathBuf; +} + pub struct UserDB { - root: String, + paths: Box<dyn UserDBPath>, } impl UserDB { - pub fn new(db_dir: &str) -> Self { + pub fn new(paths: impl UserDBPath) -> Self { Self { - root: db_dir.to_owned(), + paths: Box::new(paths), } } @@ -51,25 +56,27 @@ impl UserDB { } pub(crate) fn get_pool(&self, user_id: i64) -> Result<Arc<ConnectionPool>, FlowyError> { - let pool = open_user_db(&self.root, user_id)?; + let pool = open_user_db(self.paths.user_db_path(user_id), user_id)?; Ok(pool) } pub(crate) fn get_collab_db(&self, user_id: i64) -> Result<Arc<RocksCollabDB>, FlowyError> { - let collab_db = open_collab_db(&self.root, user_id)?; + let collab_db = open_collab_db(self.paths.collab_db_path(user_id), user_id)?; Ok(collab_db) } } -pub fn open_user_db(root: &str, user_id: i64) -> Result<Arc<ConnectionPool>, FlowyError> { +pub fn open_user_db( + db_path: impl AsRef<Path>, + user_id: i64, +) -> Result<Arc<ConnectionPool>, FlowyError> { if let Some(database) = DB_MAP.read().get(&user_id) { return Ok(database.get_pool()); } let mut write_guard = DB_MAP.write(); - let dir = user_db_path_from_uid(root, user_id); - tracing::debug!("open sqlite db {} at path: {:?}", user_id, dir); - let db = flowy_sqlite::init(&dir) + tracing::debug!("open sqlite db {} at path: {:?}", user_id, db_path.as_ref()); + let db = flowy_sqlite::init(&db_path) .map_err(|e| FlowyError::internal().with_context(format!("open user db failed, {:?}", e)))?; let pool = db.get_pool(); write_guard.insert(user_id.to_owned(), db); @@ -98,24 +105,16 @@ pub fn get_user_workspace( Ok(Some(UserWorkspace::from(row))) } -pub fn user_db_path_from_uid(root: &str, uid: i64) -> PathBuf { - let mut dir = PathBuf::new(); - dir.push(root); - dir.push(uid.to_string()); - dir -} - /// Open a collab db for the user. If the db is already opened, return the opened db. /// -pub fn open_collab_db(root: &str, uid: i64) -> Result<Arc<RocksCollabDB>, FlowyError> { +fn open_collab_db(db_path: impl AsRef<Path>, uid: i64) -> Result<Arc<RocksCollabDB>, FlowyError> { if let Some(collab_db) = COLLAB_DB_MAP.read().get(&uid) { return Ok(collab_db.clone()); } let mut write_guard = COLLAB_DB_MAP.write(); - let dir = collab_db_path_from_uid(root, uid); - tracing::trace!("open collab db {} at path: {:?}", uid, dir); - let db = match RocksCollabDB::open(dir) { + tracing::trace!("open collab db {} at path: {:?}", uid, db_path.as_ref()); + let db = match RocksCollabDB::open(db_path) { Ok(db) => Ok(db), Err(err) => { tracing::error!("open collab db failed, {:?}", err); @@ -129,14 +128,6 @@ pub fn open_collab_db(root: &str, uid: i64) -> Result<Arc<RocksCollabDB>, FlowyE Ok(db) } -pub fn collab_db_path_from_uid(root: &str, uid: i64) -> PathBuf { - let mut dir = PathBuf::new(); - dir.push(root); - dir.push(uid.to_string()); - dir.push("collab_db"); - dir -} - lazy_static! { static ref DB_MAP: RwLock<HashMap<i64, Database>> = RwLock::new(HashMap::new()); static ref COLLAB_DB_MAP: RwLock<HashMap<i64, Arc<RocksCollabDB>>> = RwLock::new(HashMap::new()); diff --git a/frontend/rust-lib/flowy-user/src/services/entities.rs b/frontend/rust-lib/flowy-user/src/services/entities.rs index e1396a723a5a..37ee2a244463 100644 --- a/frontend/rust-lib/flowy-user/src/services/entities.rs +++ b/frontend/rust-lib/flowy-user/src/services/entities.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use flowy_user_deps::entities::{AuthResponse, UserProfile, UserWorkspace}; -use flowy_user_deps::entities::{AuthType, UserAuthResponse}; +use flowy_user_deps::entities::{Authenticator, UserAuthResponse}; use crate::entities::AuthTypePB; use crate::migrations::MigrationUser; @@ -157,22 +157,22 @@ mod tests { } } -impl From<AuthTypePB> for AuthType { +impl From<AuthTypePB> for Authenticator { fn from(pb: AuthTypePB) -> Self { match pb { - AuthTypePB::Supabase => AuthType::Supabase, - AuthTypePB::Local => AuthType::Local, - AuthTypePB::AFCloud => AuthType::AFCloud, + AuthTypePB::Supabase => Authenticator::Supabase, + AuthTypePB::Local => Authenticator::Local, + AuthTypePB::AFCloud => Authenticator::AFCloud, } } } -impl From<AuthType> for AuthTypePB { - fn from(auth_type: AuthType) -> Self { +impl From<Authenticator> for AuthTypePB { + fn from(auth_type: Authenticator) -> Self { match auth_type { - AuthType::Supabase => AuthTypePB::Supabase, - AuthType::Local => AuthTypePB::Local, - AuthType::AFCloud => AuthTypePB::AFCloud, + Authenticator::Supabase => AuthTypePB::Supabase, + Authenticator::Local => AuthTypePB::Local, + Authenticator::AFCloud => AuthTypePB::AFCloud, } } } @@ -195,18 +195,18 @@ pub struct HistoricalUser { #[serde(default = "flowy_user_deps::DEFAULT_USER_NAME")] pub user_name: String, #[serde(default = "DEFAULT_AUTH_TYPE")] - pub auth_type: AuthType, + pub auth_type: Authenticator, pub sign_in_timestamp: i64, pub storage_path: String, #[serde(default)] pub device_id: String, } -const DEFAULT_AUTH_TYPE: fn() -> AuthType = || AuthType::Local; +const DEFAULT_AUTH_TYPE: fn() -> Authenticator = || Authenticator::Local; #[derive(Clone)] pub(crate) struct ResumableSignUp { pub user_profile: UserProfile, pub response: AuthResponse, - pub auth_type: AuthType, + pub authenticator: Authenticator, pub migration_user: Option<MigrationUser>, } diff --git a/frontend/rust-lib/flowy-user/src/services/historical_user.rs b/frontend/rust-lib/flowy-user/src/services/historical_user.rs index 4e40ae546799..cd62f62e2a00 100644 --- a/frontend/rust-lib/flowy-user/src/services/historical_user.rs +++ b/frontend/rust-lib/flowy-user/src/services/historical_user.rs @@ -3,7 +3,7 @@ use diesel::RunQueryDsl; use flowy_error::FlowyResult; use flowy_sqlite::schema::user_workspace_table; use flowy_sqlite::{query_dsl::*, ExpressionMethods}; -use flowy_user_deps::entities::{AuthType, UserWorkspace}; +use flowy_user_deps::entities::{Authenticator, UserWorkspace}; use lib_infra::util::timestamp; use crate::manager::UserManager; @@ -13,12 +13,12 @@ use crate::services::user_workspace_sql::UserWorkspaceTable; const HISTORICAL_USER: &str = "af_historical_users"; impl UserManager { - pub async fn get_migration_user(&self, auth_type: &AuthType) -> Option<MigrationUser> { + pub async fn get_migration_user(&self, auth_type: &Authenticator) -> Option<MigrationUser> { // Only migrate the data if the user is login in as a guest and sign up as a new user if the current // auth type is not [AuthType::Local]. let session = self.get_session().ok()?; let user_profile = self.get_user_profile(session.user_id).await.ok()?; - if user_profile.auth_type == AuthType::Local && !auth_type.is_local() { + if user_profile.authenticator == Authenticator::Local && !auth_type.is_local() { Some(MigrationUser { user_profile, session, @@ -44,7 +44,7 @@ impl UserManager { uid: i64, device_id: &str, user_name: String, - auth_type: &AuthType, + auth_type: &Authenticator, storage_path: String, ) { let mut logger_users = self @@ -86,10 +86,10 @@ impl UserManager { &self, uid: i64, device_id: String, - auth_type: AuthType, + auth_type: Authenticator, ) -> FlowyResult<()> { debug_assert!(auth_type.is_local()); - self.update_auth_type(&auth_type).await; + self.update_authenticator(&auth_type).await; let conn = self.db_connection(uid)?; let row = user_workspace_table::dsl::user_workspace_table .filter(user_workspace_table::uid.eq(uid)) diff --git a/frontend/rust-lib/flowy-user/src/services/user_sql.rs b/frontend/rust-lib/flowy-user/src/services/user_sql.rs index b60343443058..9b1a252118f7 100644 --- a/frontend/rust-lib/flowy-user/src/services/user_sql.rs +++ b/frontend/rust-lib/flowy-user/src/services/user_sql.rs @@ -29,8 +29,8 @@ impl UserTable { } } -impl From<(UserProfile, AuthType)> for UserTable { - fn from(value: (UserProfile, AuthType)) -> Self { +impl From<(UserProfile, Authenticator)> for UserTable { + fn from(value: (UserProfile, Authenticator)) -> Self { let (user_profile, auth_type) = value; let encryption_type = serde_json::to_string(&user_profile.encryption_type).unwrap_or_default(); UserTable { @@ -59,7 +59,7 @@ impl From<UserTable> for UserProfile { icon_url: table.icon_url, openai_key: table.openai_key, workspace_id: table.workspace, - auth_type: AuthType::from(table.auth_type), + authenticator: Authenticator::from(table.auth_type), encryption_type: EncryptionType::from_str(&table.encryption_type).unwrap_or_default(), stability_ai_key: table.stability_ai_key, updated_at: table.updated_at, @@ -120,8 +120,8 @@ impl From<UserUpdate> for UserTableChangeset { fn from(value: UserUpdate) -> Self { UserTableChangeset { id: value.uid.to_string(), - name: Some(value.name), - email: Some(value.email), + name: value.name, + email: value.email, ..Default::default() } } diff --git a/frontend/rust-lib/flowy-user/src/services/user_workspace.rs b/frontend/rust-lib/flowy-user/src/services/user_workspace.rs index 6002556d9cad..32840bd5fd1e 100644 --- a/frontend/rust-lib/flowy-user/src/services/user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/services/user_workspace.rs @@ -2,11 +2,13 @@ use std::convert::TryFrom; use std::sync::Arc; use collab_entity::{CollabObject, CollabType}; +use tracing::{error, instrument}; use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::schema::user_workspace_table; use flowy_sqlite::{query_dsl::*, ConnectionPool, ExpressionMethods}; use flowy_user_deps::entities::{Role, UserWorkspace, WorkspaceMember}; +use lib_dispatch::prelude::af_spawn; use crate::entities::{RepeatedUserWorkspacePB, ResetWorkspacePB}; use crate::manager::UserManager; @@ -14,8 +16,14 @@ use crate::notification::{send_notification, UserNotification}; use crate::services::user_workspace_sql::UserWorkspaceTable; impl UserManager { + #[instrument(skip(self), err)] pub async fn open_workspace(&self, workspace_id: &str) -> FlowyResult<()> { let uid = self.user_id()?; + let _ = self + .cloud_services + .get_user_service()? + .open_workspace(workspace_id) + .await; if let Some(user_workspace) = self.get_user_workspace(uid, workspace_id) { if let Err(err) = self .user_status_callback @@ -24,7 +32,7 @@ impl UserManager { .open_workspace(uid, &user_workspace) .await { - tracing::error!("Open workspace failed: {:?}", err); + error!("Open workspace failed: {:?}", err); } } Ok(()) @@ -99,8 +107,8 @@ impl UserManager { if let Ok(service) = self.cloud_services.get_user_service() { if let Ok(pool) = self.db_pool(uid) { - tokio::spawn(async move { - if let Ok(new_user_workspaces) = service.get_all_user_workspaces(uid).await { + af_spawn(async move { + if let Ok(new_user_workspaces) = service.get_all_workspace(uid).await { let _ = save_user_workspaces(uid, pool, &new_user_workspaces); let repeated_workspace_pbs = RepeatedUserWorkspacePB::from(new_user_workspaces); send_notification(&uid.to_string(), UserNotification::DidUpdateUserWorkspaces) diff --git a/frontend/rust-lib/lib-dispatch/Cargo.toml b/frontend/rust-lib/lib-dispatch/Cargo.toml index 99d22d7448a2..b8ce73de8066 100644 --- a/frontend/rust-lib/lib-dispatch/Cargo.toml +++ b/frontend/rust-lib/lib-dispatch/Cargo.toml @@ -9,30 +9,31 @@ edition = "2018" pin-project = "1.0" futures-core = { version = "0.3", default-features = false } futures-channel = "0.3.26" -futures = "0.3.26" +futures.workspace = true futures-util = "0.3.26" bytes = {version = "1.4", features = ["serde"]} -tokio = { version = "1.26", features = ["full"] } +tokio = { workspace = true, features = ["full"] } nanoid = "0.4.0" -log = "0.4.17" thread-id = "3.3.0" dyn-clone = "1.0" derivative = "2.2.0" -serde_json = {version = "1.0", optional = true } +serde_json = { workspace = true, optional = true } serde = { version = "1.0", features = ["derive"], optional = true } -serde_repr = { version = "0.1", optional = true } +serde_repr = { workspace = true, optional = true } validator = "0.16.1" +tracing.workspace = true +parking_lot = "0.12" #optional crate bincode = { version = "1.3", optional = true} -protobuf = {version = "2.28.0", optional = true} -tracing = { version = "0.1"} +protobuf = { workspace = true, optional = true } [dev-dependencies] -tokio = { version = "1.26", features = ["full"] } +tokio = { workspace = true, features = ["full"] } futures-util = "0.3.26" [features] -default = ["use_protobuf"] +default = ["use_protobuf", ] use_serde = ["bincode", "serde_json", "serde", "serde_repr"] use_protobuf= ["protobuf"] +single_thread = [] diff --git a/frontend/rust-lib/lib-dispatch/src/byte_trait.rs b/frontend/rust-lib/lib-dispatch/src/byte_trait.rs index 4548fd3cab6e..6e8996e824e6 100644 --- a/frontend/rust-lib/lib-dispatch/src/byte_trait.rs +++ b/frontend/rust-lib/lib-dispatch/src/byte_trait.rs @@ -1,6 +1,7 @@ -use crate::errors::{DispatchError, InternalError}; use bytes::Bytes; +use crate::errors::{DispatchError, InternalError}; + // To bytes pub trait ToBytes { fn into_bytes(self) -> Result<Bytes, DispatchError>; @@ -26,21 +27,6 @@ where } } -// #[cfg(feature = "use_serde")] -// impl<T> ToBytes for T -// where -// T: serde::Serialize, -// { -// fn into_bytes(self) -> Result<Bytes, DispatchError> { -// match serde_json::to_string(&self.0) { -// Ok(s) => Ok(Bytes::from(s)), -// Err(e) => Err(InternalError::SerializeToBytes(format!("{:?}", e)).into()), -// } -// } -// } - -// From bytes - pub trait AFPluginFromBytes: Sized { fn parse_from_bytes(bytes: Bytes) -> Result<Self, DispatchError>; } diff --git a/frontend/rust-lib/lib-dispatch/src/data.rs b/frontend/rust-lib/lib-dispatch/src/data.rs index 9cb028ad649f..144cf219c3b8 100644 --- a/frontend/rust-lib/lib-dispatch/src/data.rs +++ b/frontend/rust-lib/lib-dispatch/src/data.rs @@ -78,7 +78,7 @@ where fn respond_to(self, _request: &AFPluginEventRequest) -> AFPluginEventResponse { match self.into_inner().into_bytes() { Ok(bytes) => { - log::trace!( + tracing::trace!( "Serialize Data: {:?} to event response", std::any::type_name::<T>() ); diff --git a/frontend/rust-lib/lib-dispatch/src/dispatcher.rs b/frontend/rust-lib/lib-dispatch/src/dispatcher.rs index dfd0d1dcc62d..a7a05c7b92e7 100644 --- a/frontend/rust-lib/lib-dispatch/src/dispatcher.rs +++ b/frontend/rust-lib/lib-dispatch/src/dispatcher.rs @@ -1,3 +1,13 @@ +use std::any::Any; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::{future::Future, sync::Arc}; + +use derivative::*; +use pin_project::pin_project; +use tracing::event; + +use crate::module::AFPluginStateMap; use crate::runtime::AFPluginRuntime; use crate::{ errors::{DispatchError, Error, InternalError}, @@ -5,20 +15,76 @@ use crate::{ response::AFPluginEventResponse, service::{AFPluginServiceFactory, Service}, }; -use derivative::*; -use futures_core::future::BoxFuture; -use futures_util::task::Context; -use pin_project::pin_project; -use std::{future::Future, sync::Arc}; -use tokio::macros::support::{Pin, Poll}; + +#[cfg(feature = "single_thread")] +pub trait AFConcurrent {} + +#[cfg(feature = "single_thread")] +impl<T> AFConcurrent for T where T: ?Sized {} + +#[cfg(not(feature = "single_thread"))] +pub trait AFConcurrent: Send + Sync {} + +#[cfg(not(feature = "single_thread"))] +impl<T> AFConcurrent for T where T: Send + Sync {} + +#[cfg(feature = "single_thread")] +pub type AFBoxFuture<'a, T> = futures_core::future::LocalBoxFuture<'a, T>; + +#[cfg(not(feature = "single_thread"))] +pub type AFBoxFuture<'a, T> = futures_core::future::BoxFuture<'a, T>; + +pub type AFStateMap = std::sync::Arc<AFPluginStateMap>; + +#[cfg(feature = "single_thread")] +pub(crate) fn downcast_owned<T: 'static>(boxed: AFBox) -> Option<T> { + boxed.downcast().ok().map(|boxed| *boxed) +} + +#[cfg(not(feature = "single_thread"))] +pub(crate) fn downcast_owned<T: 'static + Send + Sync>(boxed: AFBox) -> Option<T> { + boxed.downcast().ok().map(|boxed| *boxed) +} + +#[cfg(feature = "single_thread")] +pub(crate) type AFBox = Box<dyn Any>; + +#[cfg(not(feature = "single_thread"))] +pub(crate) type AFBox = Box<dyn Any + Send + Sync>; + +#[cfg(feature = "single_thread")] +pub type BoxFutureCallback = + Box<dyn FnOnce(AFPluginEventResponse) -> AFBoxFuture<'static, ()> + 'static>; + +#[cfg(not(feature = "single_thread"))] +pub type BoxFutureCallback = + Box<dyn FnOnce(AFPluginEventResponse) -> AFBoxFuture<'static, ()> + Send + Sync + 'static>; + +#[cfg(feature = "single_thread")] +pub fn af_spawn<T>(future: T) -> tokio::task::JoinHandle<T::Output> +where + T: Future + Send + 'static, + T::Output: Send + 'static, +{ + tokio::spawn(future) +} + +#[cfg(not(feature = "single_thread"))] +pub fn af_spawn<T>(future: T) -> tokio::task::JoinHandle<T::Output> +where + T: Future + Send + 'static, + T::Output: Send + 'static, +{ + tokio::spawn(future) +} pub struct AFPluginDispatcher { plugins: AFPluginMap, - runtime: AFPluginRuntime, + runtime: Arc<AFPluginRuntime>, } impl AFPluginDispatcher { - pub fn construct<F>(runtime: AFPluginRuntime, module_factory: F) -> AFPluginDispatcher + pub fn construct<F>(runtime: Arc<AFPluginRuntime>, module_factory: F) -> AFPluginDispatcher where F: FnOnce() -> Vec<AFPlugin>, { @@ -30,24 +96,77 @@ impl AFPluginDispatcher { } } - pub fn async_send<Req>( + pub async fn async_send<Req>( + dispatch: Arc<AFPluginDispatcher>, + request: Req, + ) -> AFPluginEventResponse + where + Req: Into<AFPluginRequest>, + { + AFPluginDispatcher::async_send_with_callback(dispatch, request, |_| Box::pin(async {})).await + } + + pub async fn async_send_with_callback<Req, Callback>( + dispatch: Arc<AFPluginDispatcher>, + request: Req, + callback: Callback, + ) -> AFPluginEventResponse + where + Req: Into<AFPluginRequest>, + Callback: FnOnce(AFPluginEventResponse) -> AFBoxFuture<'static, ()> + AFConcurrent + 'static, + { + let request: AFPluginRequest = request.into(); + let plugins = dispatch.plugins.clone(); + let service = Box::new(DispatchService { plugins }); + tracing::trace!("Async event: {:?}", &request.event); + let service_ctx = DispatchContext { + request, + callback: Some(Box::new(callback)), + }; + + // Spawns a future onto the runtime. + // + // This spawns the given future onto the runtime's executor, usually a + // thread pool. The thread pool is then responsible for polling the future + // until it completes. + // + // The provided future will start running in the background immediately + // when `spawn` is called, even if you don't await the returned + // `JoinHandle`. + let handle = dispatch.runtime.spawn(async move { + service.call(service_ctx).await.unwrap_or_else(|e| { + tracing::error!("Dispatch runtime error: {:?}", e); + InternalError::Other(format!("{:?}", e)).as_response() + }) + }); + + let result = dispatch.runtime.run_until(handle).await; + result.unwrap_or_else(|e| { + let msg = format!("EVENT_DISPATCH join error: {:?}", e); + tracing::error!("{}", msg); + let error = InternalError::JoinError(msg); + error.as_response() + }) + } + + pub fn box_async_send<Req>( dispatch: Arc<AFPluginDispatcher>, request: Req, ) -> DispatchFuture<AFPluginEventResponse> where - Req: std::convert::Into<AFPluginRequest>, + Req: Into<AFPluginRequest> + 'static, { - AFPluginDispatcher::async_send_with_callback(dispatch, request, |_| Box::pin(async {})) + AFPluginDispatcher::boxed_async_send_with_callback(dispatch, request, |_| Box::pin(async {})) } - pub fn async_send_with_callback<Req, Callback>( + pub fn boxed_async_send_with_callback<Req, Callback>( dispatch: Arc<AFPluginDispatcher>, request: Req, callback: Callback, ) -> DispatchFuture<AFPluginEventResponse> where - Req: std::convert::Into<AFPluginRequest>, - Callback: FnOnce(AFPluginEventResponse) -> BoxFuture<'static, ()> + 'static + Send + Sync, + Req: Into<AFPluginRequest> + 'static, + Callback: FnOnce(AFPluginEventResponse) -> AFBoxFuture<'static, ()> + AFConcurrent + 'static, { let request: AFPluginRequest = request.into(); let plugins = dispatch.plugins.clone(); @@ -57,7 +176,17 @@ impl AFPluginDispatcher { request, callback: Some(Box::new(callback)), }; - let join_handle = dispatch.runtime.spawn(async move { + + // Spawns a future onto the runtime. + // + // This spawns the given future onto the runtime's executor, usually a + // thread pool. The thread pool is then responsible for polling the future + // until it completes. + // + // The provided future will start running in the background immediately + // when `spawn` is called, even if you don't await the returned + // `JoinHandle`. + let handle = dispatch.runtime.spawn(async move { service.call(service_ctx).await.unwrap_or_else(|e| { tracing::error!("Dispatch runtime error: {:?}", e); InternalError::Other(format!("{:?}", e)).as_response() @@ -66,7 +195,8 @@ impl AFPluginDispatcher { DispatchFuture { fut: Box::pin(async move { - join_handle.await.unwrap_or_else(|e| { + let result = dispatch.runtime.run_until(handle).await; + result.unwrap_or_else(|e| { let msg = format!("EVENT_DISPATCH join error: {:?}", e); tracing::error!("{}", msg); let error = InternalError::JoinError(msg); @@ -76,44 +206,56 @@ impl AFPluginDispatcher { } } + #[cfg(not(feature = "single_thread"))] pub fn sync_send( dispatch: Arc<AFPluginDispatcher>, request: AFPluginRequest, ) -> AFPluginEventResponse { - futures::executor::block_on(async { - AFPluginDispatcher::async_send_with_callback(dispatch, request, |_| Box::pin(async {})).await - }) + futures::executor::block_on(AFPluginDispatcher::async_send_with_callback( + dispatch, + request, + |_| Box::pin(async {}), + )) } - pub fn spawn<F>(&self, f: F) + #[cfg(feature = "single_thread")] + #[track_caller] + pub fn spawn<F>(&self, future: F) -> tokio::task::JoinHandle<F::Output> where - F: Future<Output = ()> + Send + 'static, + F: Future + 'static, { - self.runtime.spawn(f); + self.runtime.spawn(future) } -} -#[pin_project] -pub struct DispatchFuture<T: Send + Sync> { - #[pin] - pub fut: Pin<Box<dyn Future<Output = T> + Sync + Send>>, -} + #[cfg(not(feature = "single_thread"))] + #[track_caller] + pub fn spawn<F>(&self, future: F) -> tokio::task::JoinHandle<F::Output> + where + F: Future + Send + 'static, + <F as Future>::Output: Send + 'static, + { + self.runtime.spawn(future) + } -impl<T> Future for DispatchFuture<T> -where - T: Send + Sync, -{ - type Output = T; + #[cfg(feature = "single_thread")] + pub async fn run_until<F>(&self, future: F) -> F::Output + where + F: Future + 'static, + { + let handle = self.runtime.spawn(future); + self.runtime.run_until(handle).await.unwrap() + } - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { - let this = self.as_mut().project(); - Poll::Ready(futures_core::ready!(this.fut.poll(cx))) + #[cfg(not(feature = "single_thread"))] + pub async fn run_until<'a, F>(&self, future: F) -> F::Output + where + F: Future + Send + 'a, + <F as Future>::Output: Send + 'a, + { + self.runtime.run_until(future).await } } -pub type BoxFutureCallback = - Box<dyn FnOnce(AFPluginEventResponse) -> BoxFuture<'static, ()> + 'static + Send + Sync>; - #[derive(Derivative)] #[derivative(Debug)] pub struct DispatchContext { @@ -136,36 +278,37 @@ pub(crate) struct DispatchService { impl Service<DispatchContext> for DispatchService { type Response = AFPluginEventResponse; type Error = DispatchError; - type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>; + type Future = AFBoxFuture<'static, Result<Self::Response, Self::Error>>; - #[cfg_attr( - feature = "use_tracing", - tracing::instrument(name = "DispatchService", level = "debug", skip(self, ctx)) - )] + #[tracing::instrument(name = "DispatchService", level = "debug", skip(self, ctx))] fn call(&self, ctx: DispatchContext) -> Self::Future { let module_map = self.plugins.clone(); let (request, callback) = ctx.into_parts(); Box::pin(async move { let result = { - // print_module_map_info(&module_map); match module_map.get(&request.event) { Some(module) => { - tracing::trace!("Handle event: {:?} by {:?}", &request.event, module.name); + event!( + tracing::Level::TRACE, + "Handle event: {:?} by {:?}", + &request.event, + module.name + ); let fut = module.new_service(()); let service_fut = fut.await?.call(request); service_fut.await }, None => { let msg = format!("Can not find the event handler. {:?}", request); - tracing::error!("{}", msg); + event!(tracing::Level::ERROR, "{}", msg); Err(InternalError::HandleNotFound(msg).into()) }, } }; let response = result.unwrap_or_else(|e| e.into()); - tracing::trace!("Dispatch result: {:?}", response); + event!(tracing::Level::TRACE, "Dispatch result: {:?}", response); if let Some(callback) = callback { callback(response.clone()).await; } @@ -190,3 +333,21 @@ fn print_plugins(plugins: &AFPluginMap) { tracing::info!("Event: {:?} plugin : {:?}", k, v.name); }) } + +#[pin_project] +pub struct DispatchFuture<T: AFConcurrent> { + #[pin] + pub fut: Pin<Box<dyn Future<Output = T> + 'static>>, +} + +impl<T> Future for DispatchFuture<T> +where + T: AFConcurrent + 'static, +{ + type Output = T; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { + let this = self.as_mut().project(); + Poll::Ready(futures_core::ready!(this.fut.poll(cx))) + } +} diff --git a/frontend/rust-lib/lib-dispatch/src/errors/errors.rs b/frontend/rust-lib/lib-dispatch/src/errors/errors.rs index cbfd8542ecf6..19f0e336c160 100644 --- a/frontend/rust-lib/lib-dispatch/src/errors/errors.rs +++ b/frontend/rust-lib/lib-dispatch/src/errors/errors.rs @@ -1,15 +1,17 @@ +use std::fmt; + +use bytes::Bytes; +use dyn_clone::DynClone; +use tokio::{sync::mpsc::error::SendError, task::JoinError}; + +use crate::prelude::AFConcurrent; use crate::{ byte_trait::AFPluginFromBytes, request::AFPluginEventRequest, response::{AFPluginEventResponse, ResponseBuilder}, }; -use bytes::Bytes; -use dyn_clone::DynClone; - -use std::fmt; -use tokio::{sync::mpsc::error::SendError, task::JoinError}; -pub trait Error: fmt::Debug + DynClone + Send + Sync { +pub trait Error: fmt::Debug + DynClone + AFConcurrent { fn as_response(&self) -> AFPluginEventResponse; } diff --git a/frontend/rust-lib/lib-dispatch/src/module/container.rs b/frontend/rust-lib/lib-dispatch/src/module/container.rs index a95b3b702ec5..d6fdf24d6795 100644 --- a/frontend/rust-lib/lib-dispatch/src/module/container.rs +++ b/frontend/rust-lib/lib-dispatch/src/module/container.rs @@ -1,10 +1,9 @@ -use std::{ - any::{Any, TypeId}, - collections::HashMap, -}; +use std::{any::TypeId, collections::HashMap}; + +use crate::prelude::{downcast_owned, AFBox, AFConcurrent}; #[derive(Default, Debug)] -pub struct AFPluginStateMap(HashMap<TypeId, Box<dyn Any + Sync + Send>>); +pub struct AFPluginStateMap(HashMap<TypeId, AFBox>); impl AFPluginStateMap { #[inline] @@ -14,7 +13,7 @@ impl AFPluginStateMap { pub fn insert<T>(&mut self, val: T) -> Option<T> where - T: 'static + Send + Sync, + T: 'static + AFConcurrent, { self .0 @@ -24,14 +23,14 @@ impl AFPluginStateMap { pub fn remove<T>(&mut self) -> Option<T> where - T: 'static + Send + Sync, + T: 'static + AFConcurrent, { self.0.remove(&TypeId::of::<T>()).and_then(downcast_owned) } pub fn get<T>(&self) -> Option<&T> where - T: 'static + Send + Sync, + T: 'static, { self .0 @@ -41,7 +40,7 @@ impl AFPluginStateMap { pub fn get_mut<T>(&mut self) -> Option<&mut T> where - T: 'static + Send + Sync, + T: 'static + AFConcurrent, { self .0 @@ -51,7 +50,7 @@ impl AFPluginStateMap { pub fn contains<T>(&self) -> bool where - T: 'static + Send + Sync, + T: 'static + AFConcurrent, { self.0.contains_key(&TypeId::of::<T>()) } @@ -60,7 +59,3 @@ impl AFPluginStateMap { self.0.extend(other.0); } } - -fn downcast_owned<T: 'static + Send + Sync>(boxed: Box<dyn Any + Send + Sync>) -> Option<T> { - boxed.downcast().ok().map(|boxed| *boxed) -} diff --git a/frontend/rust-lib/lib-dispatch/src/module/data.rs b/frontend/rust-lib/lib-dispatch/src/module/data.rs index 809954668893..520c3e24945c 100644 --- a/frontend/rust-lib/lib-dispatch/src/module/data.rs +++ b/frontend/rust-lib/lib-dispatch/src/module/data.rs @@ -1,15 +1,17 @@ +use std::{any::type_name, ops::Deref, sync::Arc}; + +use crate::prelude::AFConcurrent; use crate::{ errors::{DispatchError, InternalError}, request::{payload::Payload, AFPluginEventRequest, FromAFPluginRequest}, util::ready::{ready, Ready}, }; -use std::{any::type_name, ops::Deref, sync::Arc}; -pub struct AFPluginState<T: ?Sized + Send + Sync>(Arc<T>); +pub struct AFPluginState<T: ?Sized + AFConcurrent>(Arc<T>); impl<T> AFPluginState<T> where - T: Send + Sync, + T: AFConcurrent, { pub fn new(data: T) -> Self { AFPluginState(Arc::new(data)) @@ -22,7 +24,7 @@ where impl<T> Deref for AFPluginState<T> where - T: ?Sized + Send + Sync, + T: ?Sized + AFConcurrent, { type Target = Arc<T>; @@ -33,7 +35,7 @@ where impl<T> Clone for AFPluginState<T> where - T: ?Sized + Send + Sync, + T: ?Sized + AFConcurrent, { fn clone(&self) -> AFPluginState<T> { AFPluginState(self.0.clone()) @@ -42,7 +44,7 @@ where impl<T> From<Arc<T>> for AFPluginState<T> where - T: ?Sized + Send + Sync, + T: ?Sized + AFConcurrent, { fn from(arc: Arc<T>) -> Self { AFPluginState(arc) @@ -51,7 +53,7 @@ where impl<T> FromAFPluginRequest for AFPluginState<T> where - T: ?Sized + Send + Sync + 'static, + T: ?Sized + AFConcurrent + 'static, { type Error = DispatchError; type Future = Ready<Result<Self, DispatchError>>; @@ -59,13 +61,13 @@ where #[inline] fn from_request(req: &AFPluginEventRequest, _: &mut Payload) -> Self::Future { if let Some(state) = req.get_state::<AFPluginState<T>>() { - ready(Ok(state.clone())) + ready(Ok(state)) } else { let msg = format!( "Failed to get the plugin state of type: {}", type_name::<T>() ); - log::error!("{}", msg,); + tracing::error!("{}", msg,); ready(Err(InternalError::Other(msg).into())) } } diff --git a/frontend/rust-lib/lib-dispatch/src/module/mod.rs b/frontend/rust-lib/lib-dispatch/src/module/mod.rs index 7c8d1a344068..9527a890b353 100644 --- a/frontend/rust-lib/lib-dispatch/src/module/mod.rs +++ b/frontend/rust-lib/lib-dispatch/src/module/mod.rs @@ -1,4 +1,5 @@ #![allow(clippy::module_inception)] + pub use container::*; pub use data::*; pub use module::*; diff --git a/frontend/rust-lib/lib-dispatch/src/module/module.rs b/frontend/rust-lib/lib-dispatch/src/module/module.rs index 09c72fe245a1..0eb162b51526 100644 --- a/frontend/rust-lib/lib-dispatch/src/module/module.rs +++ b/frontend/rust-lib/lib-dispatch/src/module/module.rs @@ -9,15 +9,15 @@ use std::{ task::{Context, Poll}, }; -use futures_core::future::BoxFuture; use futures_core::ready; use nanoid::nanoid; use pin_project::pin_project; +use crate::dispatcher::AFConcurrent; +use crate::prelude::{AFBoxFuture, AFStateMap}; use crate::service::AFPluginHandler; use crate::{ errors::{DispatchError, InternalError}, - module::{container::AFPluginStateMap, AFPluginState}, request::{payload::Payload, AFPluginEventRequest, FromAFPluginRequest}, response::{AFPluginEventResponse, AFPluginResponder}, service::{ @@ -58,7 +58,7 @@ pub struct AFPlugin { pub name: String, /// a list of `AFPluginState` that the plugin registers. The state can be read by the plugin's handler. - states: Arc<AFPluginStateMap>, + states: AFStateMap, /// Contains a list of factories that are used to generate the services used to handle the passed-in /// `ServiceRequest`. @@ -72,7 +72,7 @@ impl std::default::Default for AFPlugin { fn default() -> Self { Self { name: "".to_owned(), - states: Arc::new(AFPluginStateMap::new()), + states: Default::default(), event_service_factory: Arc::new(HashMap::new()), } } @@ -88,11 +88,10 @@ impl AFPlugin { self } - pub fn state<D: 'static + Send + Sync>(mut self, data: D) -> Self { + pub fn state<D: AFConcurrent + 'static>(mut self, data: D) -> Self { Arc::get_mut(&mut self.states) .unwrap() - .insert(AFPluginState::new(data)); - + .insert(crate::module::AFPluginState::new(data)); self } @@ -100,9 +99,9 @@ impl AFPlugin { pub fn event<E, H, T, R>(mut self, event: E, handler: H) -> Self where H: AFPluginHandler<T, R>, - T: FromAFPluginRequest + 'static + Send + Sync, - <T as FromAFPluginRequest>::Future: Sync + Send, - R: Future + 'static + Send + Sync, + T: FromAFPluginRequest + 'static + AFConcurrent, + <T as FromAFPluginRequest>::Future: AFConcurrent, + R: Future + AFConcurrent + 'static, R::Output: AFPluginResponder + 'static, E: Eq + Hash + Debug + Clone + Display, { @@ -169,7 +168,7 @@ impl AFPluginServiceFactory<AFPluginRequest> for AFPlugin { type Error = DispatchError; type Service = BoxService<AFPluginRequest, Self::Response, Self::Error>; type Context = (); - type Future = BoxFuture<'static, Result<Self::Service, Self::Error>>; + type Future = AFBoxFuture<'static, Result<Self::Service, Self::Error>>; fn new_service(&self, _cfg: Self::Context) -> Self::Future { let services = self.event_service_factory.clone(); @@ -185,13 +184,14 @@ pub struct AFPluginService { services: Arc< HashMap<AFPluginEvent, BoxServiceFactory<(), ServiceRequest, ServiceResponse, DispatchError>>, >, - states: Arc<AFPluginStateMap>, + states: AFStateMap, } impl Service<AFPluginRequest> for AFPluginService { type Response = AFPluginEventResponse; type Error = DispatchError; - type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>; + + type Future = AFBoxFuture<'static, Result<Self::Response, Self::Error>>; fn call(&self, request: AFPluginRequest) -> Self::Future { let AFPluginRequest { id, event, payload } = request; @@ -224,7 +224,7 @@ impl Service<AFPluginRequest> for AFPluginService { #[pin_project] pub struct AFPluginServiceFuture { #[pin] - fut: BoxFuture<'static, Result<ServiceResponse, DispatchError>>, + fut: AFBoxFuture<'static, Result<ServiceResponse, DispatchError>>, } impl Future for AFPluginServiceFuture { diff --git a/frontend/rust-lib/lib-dispatch/src/request/payload.rs b/frontend/rust-lib/lib-dispatch/src/request/payload.rs index c371537f3674..6fc2a98e992b 100644 --- a/frontend/rust-lib/lib-dispatch/src/request/payload.rs +++ b/frontend/rust-lib/lib-dispatch/src/request/payload.rs @@ -1,9 +1,7 @@ -use bytes::Bytes; use std::{fmt, fmt::Formatter}; -pub enum PayloadError {} +use bytes::Bytes; -// TODO: support stream data #[derive(Clone)] #[cfg_attr(feature = "use_serde", derive(serde::Serialize))] pub enum Payload { diff --git a/frontend/rust-lib/lib-dispatch/src/request/request.rs b/frontend/rust-lib/lib-dispatch/src/request/request.rs index 20af0bbc02c6..c62950f65d05 100644 --- a/frontend/rust-lib/lib-dispatch/src/request/request.rs +++ b/frontend/rust-lib/lib-dispatch/src/request/request.rs @@ -1,19 +1,20 @@ use std::future::Future; +use std::{ + fmt::Debug, + pin::Pin, + task::{Context, Poll}, +}; + +use derivative::*; +use futures_core::ready; +use crate::prelude::{AFConcurrent, AFStateMap}; use crate::{ errors::{DispatchError, InternalError}, - module::{AFPluginEvent, AFPluginStateMap}, + module::AFPluginEvent, request::payload::Payload, util::ready::{ready, Ready}, }; -use derivative::*; -use futures_core::ready; -use std::{ - fmt::Debug, - pin::Pin, - sync::Arc, - task::{Context, Poll}, -}; #[derive(Clone, Debug, Derivative)] pub struct AFPluginEventRequest { @@ -21,27 +22,27 @@ pub struct AFPluginEventRequest { pub(crate) id: String, pub(crate) event: AFPluginEvent, #[derivative(Debug = "ignore")] - pub(crate) states: Arc<AFPluginStateMap>, + pub(crate) states: AFStateMap, } impl AFPluginEventRequest { - pub fn new<E>(id: String, event: E, module_data: Arc<AFPluginStateMap>) -> AFPluginEventRequest + pub fn new<E>(id: String, event: E, states: AFStateMap) -> AFPluginEventRequest where E: Into<AFPluginEvent>, { Self { id, event: event.into(), - states: module_data, + states, } } - pub fn get_state<T: 'static>(&self) -> Option<&T> + pub fn get_state<T>(&self) -> Option<T> where - T: Send + Sync, + T: AFConcurrent + 'static + Clone, { if let Some(data) = self.states.get::<T>() { - return Some(data); + return Some(data.clone()); } None @@ -79,7 +80,7 @@ impl FromAFPluginRequest for String { } pub fn unexpected_none_payload(request: &AFPluginEventRequest) -> DispatchError { - log::warn!("{:?} expected payload", &request.event); + tracing::warn!("{:?} expected payload", &request.event); InternalError::UnexpectedNone("Expected payload".to_string()).into() } diff --git a/frontend/rust-lib/lib-dispatch/src/runtime.rs b/frontend/rust-lib/lib-dispatch/src/runtime.rs index 656612b359cb..691c86225009 100644 --- a/frontend/rust-lib/lib-dispatch/src/runtime.rs +++ b/frontend/rust-lib/lib-dispatch/src/runtime.rs @@ -1,24 +1,117 @@ -use std::{io, thread}; +use std::fmt::{Display, Formatter}; +use std::future::Future; +use std::io; + use tokio::runtime; +use tokio::runtime::Runtime; +use tokio::task::JoinHandle; + +pub struct AFPluginRuntime { + inner: Runtime, + #[cfg(feature = "single_thread")] + local: tokio::task::LocalSet, +} + +impl Display for AFPluginRuntime { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if cfg!(feature = "single_thread") { + write!(f, "Runtime(single_thread)") + } else { + write!(f, "Runtime(multi_thread)") + } + } +} + +impl AFPluginRuntime { + pub fn new() -> io::Result<Self> { + let inner = default_tokio_runtime()?; + Ok(Self { + inner, + #[cfg(feature = "single_thread")] + local: tokio::task::LocalSet::new(), + }) + } + + #[cfg(feature = "single_thread")] + #[track_caller] + pub fn spawn<F>(&self, future: F) -> JoinHandle<F::Output> + where + F: Future + 'static, + { + self.local.spawn_local(future) + } + + #[cfg(not(feature = "single_thread"))] + #[track_caller] + pub fn spawn<F>(&self, future: F) -> JoinHandle<F::Output> + where + F: Future + Send + 'static, + <F as Future>::Output: Send + 'static, + { + self.inner.spawn(future) + } -pub type AFPluginRuntime = tokio::runtime::Runtime; + #[cfg(feature = "single_thread")] + pub async fn run_until<F>(&self, future: F) -> F::Output + where + F: Future, + { + self.local.run_until(future).await + } + + #[cfg(not(feature = "single_thread"))] + pub async fn run_until<F>(&self, future: F) -> F::Output + where + F: Future, + { + future.await + } + + #[cfg(feature = "single_thread")] + #[track_caller] + pub fn block_on<F>(&self, f: F) -> F::Output + where + F: Future, + { + self.local.block_on(&self.inner, f) + } + + #[cfg(not(feature = "single_thread"))] + #[track_caller] + pub fn block_on<F>(&self, f: F) -> F::Output + where + F: Future, + { + self.inner.block_on(f) + } +} + +#[cfg(feature = "single_thread")] +pub fn default_tokio_runtime() -> io::Result<Runtime> { + runtime::Builder::new_current_thread() + .thread_name("dispatch-rt-st") + .enable_io() + .enable_time() + .build() +} -pub fn tokio_default_runtime() -> io::Result<AFPluginRuntime> { +#[cfg(not(feature = "single_thread"))] +pub fn default_tokio_runtime() -> io::Result<Runtime> { runtime::Builder::new_multi_thread() - .thread_name("dispatch-rt") + .thread_name("dispatch-rt-mt") .enable_io() .enable_time() .on_thread_start(move || { tracing::trace!( "{:?} thread started: thread_id= {}", - thread::current(), + std::thread::current(), thread_id::get() ); }) .on_thread_stop(move || { tracing::trace!( "{:?} thread stopping: thread_id= {}", - thread::current(), + std::thread::current(), thread_id::get(), ); }) diff --git a/frontend/rust-lib/lib-dispatch/src/service/boxed.rs b/frontend/rust-lib/lib-dispatch/src/service/boxed.rs index 6d2a72e8439b..76780e1d30fe 100644 --- a/frontend/rust-lib/lib-dispatch/src/service/boxed.rs +++ b/frontend/rust-lib/lib-dispatch/src/service/boxed.rs @@ -1,21 +1,33 @@ +use crate::prelude::{AFBoxFuture, AFConcurrent}; use crate::service::{AFPluginServiceFactory, Service}; -use futures_core::future::BoxFuture; pub fn factory<SF, Req>(factory: SF) -> BoxServiceFactory<SF::Context, Req, SF::Response, SF::Error> where - SF: AFPluginServiceFactory<Req> + 'static + Sync + Send, + SF: AFPluginServiceFactory<Req> + 'static + AFConcurrent, Req: 'static, SF::Response: 'static, SF::Service: 'static, SF::Future: 'static, - SF::Error: 'static + Send + Sync, - <SF as AFPluginServiceFactory<Req>>::Service: Sync + Send, - <<SF as AFPluginServiceFactory<Req>>::Service as Service<Req>>::Future: Send + Sync, - <SF as AFPluginServiceFactory<Req>>::Future: Send + Sync, + SF::Error: 'static, + <SF as AFPluginServiceFactory<Req>>::Service: AFConcurrent, + <<SF as AFPluginServiceFactory<Req>>::Service as Service<Req>>::Future: AFConcurrent, + <SF as AFPluginServiceFactory<Req>>::Future: AFConcurrent, { BoxServiceFactory(Box::new(FactoryWrapper(factory))) } +#[cfg(feature = "single_thread")] +type Inner<Cfg, Req, Res, Err> = Box< + dyn AFPluginServiceFactory< + Req, + Context = Cfg, + Response = Res, + Error = Err, + Service = BoxService<Req, Res, Err>, + Future = AFBoxFuture<'static, Result<BoxService<Req, Res, Err>, Err>>, + >, +>; +#[cfg(not(feature = "single_thread"))] type Inner<Cfg, Req, Res, Err> = Box< dyn AFPluginServiceFactory< Req, @@ -23,9 +35,9 @@ type Inner<Cfg, Req, Res, Err> = Box< Response = Res, Error = Err, Service = BoxService<Req, Res, Err>, - Future = BoxFuture<'static, Result<BoxService<Req, Res, Err>, Err>>, - > + Sync - + Send, + Future = AFBoxFuture<'static, Result<BoxService<Req, Res, Err>, Err>>, + > + Send + + Sync, >; pub struct BoxServiceFactory<Cfg, Req, Res, Err>(Inner<Cfg, Req, Res, Err>); @@ -39,15 +51,21 @@ where type Error = Err; type Service = BoxService<Req, Res, Err>; type Context = Cfg; - type Future = BoxFuture<'static, Result<Self::Service, Self::Error>>; + type Future = AFBoxFuture<'static, Result<Self::Service, Self::Error>>; fn new_service(&self, cfg: Cfg) -> Self::Future { self.0.new_service(cfg) } } +#[cfg(feature = "single_thread")] +pub type BoxService<Req, Res, Err> = Box< + dyn Service<Req, Response = Res, Error = Err, Future = AFBoxFuture<'static, Result<Res, Err>>>, +>; + +#[cfg(not(feature = "single_thread"))] pub type BoxService<Req, Res, Err> = Box< - dyn Service<Req, Response = Res, Error = Err, Future = BoxFuture<'static, Result<Res, Err>>> + dyn Service<Req, Response = Res, Error = Err, Future = AFBoxFuture<'static, Result<Res, Err>>> + Sync + Send, >; @@ -88,11 +106,11 @@ impl<S> ServiceWrapper<S> { impl<S, Req, Res, Err> Service<Req> for ServiceWrapper<S> where S: Service<Req, Response = Res, Error = Err>, - S::Future: 'static + Send + Sync, + S::Future: 'static + AFConcurrent, { type Response = Res; type Error = Err; - type Future = BoxFuture<'static, Result<Res, Err>>; + type Future = AFBoxFuture<'static, Result<Res, Err>>; fn call(&self, req: Req) -> Self::Future { Box::pin(self.inner.call(req)) @@ -108,15 +126,15 @@ where Err: 'static, SF: AFPluginServiceFactory<Req, Context = Cfg, Response = Res, Error = Err>, SF::Future: 'static, - SF::Service: 'static + Send + Sync, - <<SF as AFPluginServiceFactory<Req>>::Service as Service<Req>>::Future: Send + Sync + 'static, - <SF as AFPluginServiceFactory<Req>>::Future: Send + Sync, + SF::Service: 'static + AFConcurrent, + <<SF as AFPluginServiceFactory<Req>>::Service as Service<Req>>::Future: AFConcurrent + 'static, + <SF as AFPluginServiceFactory<Req>>::Future: AFConcurrent, { type Response = Res; type Error = Err; type Service = BoxService<Req, Res, Err>; type Context = Cfg; - type Future = BoxFuture<'static, Result<Self::Service, Self::Error>>; + type Future = AFBoxFuture<'static, Result<Self::Service, Self::Error>>; fn new_service(&self, cfg: Cfg) -> Self::Future { let f = self.0.new_service(cfg); diff --git a/frontend/rust-lib/lib-dispatch/src/service/handler.rs b/frontend/rust-lib/lib-dispatch/src/service/handler.rs index c55d4d0b166e..d231ed27f17f 100644 --- a/frontend/rust-lib/lib-dispatch/src/service/handler.rs +++ b/frontend/rust-lib/lib-dispatch/src/service/handler.rs @@ -8,18 +8,19 @@ use std::{ use futures_core::ready; use pin_project::pin_project; +use crate::dispatcher::AFConcurrent; use crate::{ errors::DispatchError, - request::{payload::Payload, AFPluginEventRequest, FromAFPluginRequest}, + request::{AFPluginEventRequest, FromAFPluginRequest}, response::{AFPluginEventResponse, AFPluginResponder}, service::{AFPluginServiceFactory, Service, ServiceRequest, ServiceResponse}, util::ready::*, }; /// A closure that is run every time for the specified plugin event -pub trait AFPluginHandler<T, R>: Clone + 'static + Sync + Send +pub trait AFPluginHandler<T, R>: Clone + AFConcurrent + 'static where - R: Future + Send + Sync, + R: Future + AFConcurrent, R::Output: AFPluginResponder, { fn call(&self, param: T) -> R; @@ -29,7 +30,7 @@ pub struct AFPluginHandlerService<H, T, R> where H: AFPluginHandler<T, R>, T: FromAFPluginRequest, - R: Future + Sync + Send, + R: Future + AFConcurrent, R::Output: AFPluginResponder, { handler: H, @@ -40,7 +41,7 @@ impl<H, T, R> AFPluginHandlerService<H, T, R> where H: AFPluginHandler<T, R>, T: FromAFPluginRequest, - R: Future + Sync + Send, + R: Future + AFConcurrent, R::Output: AFPluginResponder, { pub fn new(handler: H) -> Self { @@ -55,7 +56,7 @@ impl<H, T, R> Clone for AFPluginHandlerService<H, T, R> where H: AFPluginHandler<T, R>, T: FromAFPluginRequest, - R: Future + Sync + Send, + R: Future + AFConcurrent, R::Output: AFPluginResponder, { fn clone(&self) -> Self { @@ -70,7 +71,7 @@ impl<F, T, R> AFPluginServiceFactory<ServiceRequest> for AFPluginHandlerService< where F: AFPluginHandler<T, R>, T: FromAFPluginRequest, - R: Future + Send + Sync, + R: Future + AFConcurrent, R::Output: AFPluginResponder, { type Response = ServiceResponse; @@ -88,7 +89,7 @@ impl<H, T, R> Service<ServiceRequest> for AFPluginHandlerService<H, T, R> where H: AFPluginHandler<T, R>, T: FromAFPluginRequest, - R: Future + Sync + Send, + R: Future + AFConcurrent, R::Output: AFPluginResponder, { type Response = ServiceResponse; @@ -107,7 +108,7 @@ pub enum HandlerServiceFuture<H, T, R> where H: AFPluginHandler<T, R>, T: FromAFPluginRequest, - R: Future + Sync + Send, + R: Future + AFConcurrent, R::Output: AFPluginResponder, { Extract(#[pin] T::Future, Option<AFPluginEventRequest>, H), @@ -118,7 +119,7 @@ impl<F, T, R> Future for HandlerServiceFuture<F, T, R> where F: AFPluginHandler<T, R>, T: FromAFPluginRequest, - R: Future + Sync + Send, + R: Future + AFConcurrent, R::Output: AFPluginResponder, { type Output = Result<ServiceResponse, DispatchError>; @@ -154,8 +155,8 @@ where macro_rules! factory_tuple ({ $($param:ident)* } => { impl<Func, $($param,)* Res> AFPluginHandler<($($param,)*), Res> for Func - where Func: Fn($($param),*) -> Res + Clone + 'static + Sync + Send, - Res: Future + Sync + Send, + where Func: Fn($($param),*) -> Res + Clone + 'static + AFConcurrent, + Res: Future + AFConcurrent, Res::Output: AFPluginResponder, { #[allow(non_snake_case)] @@ -181,7 +182,7 @@ macro_rules! tuple_from_req ({$tuple_type:ident, $(($n:tt, $T:ident)),+} => { type Error = DispatchError; type Future = $tuple_type<$($T),+>; - fn from_request(req: &AFPluginEventRequest, payload: &mut Payload) -> Self::Future { + fn from_request(req: &AFPluginEventRequest, payload: &mut crate::prelude::Payload) -> Self::Future { $tuple_type { items: <($(Option<$T>,)+)>::default(), futs: FromRequestFutures($($T::from_request(req, payload),)+), diff --git a/frontend/rust-lib/lib-dispatch/tests/api/module.rs b/frontend/rust-lib/lib-dispatch/tests/api/module.rs index cf68fa583b48..4e105a8257e6 100644 --- a/frontend/rust-lib/lib-dispatch/tests/api/module.rs +++ b/frontend/rust-lib/lib-dispatch/tests/api/module.rs @@ -1,7 +1,8 @@ -use lib_dispatch::prelude::*; -use lib_dispatch::runtime::tokio_default_runtime; use std::sync::Arc; +use lib_dispatch::prelude::*; +use lib_dispatch::runtime::AFPluginRuntime; + pub async fn hello() -> String { "say hello".to_string() } @@ -9,7 +10,7 @@ pub async fn hello() -> String { #[tokio::test] async fn test() { let event = "1"; - let runtime = tokio_default_runtime().unwrap(); + let runtime = Arc::new(AFPluginRuntime::new().unwrap()); let dispatch = Arc::new(AFPluginDispatcher::construct(runtime, || { vec![AFPlugin::new().event(event, hello)] })); diff --git a/frontend/rust-lib/lib-log/Cargo.toml b/frontend/rust-lib/lib-log/Cargo.toml index a02dcbed73cd..1a4e9933fbe1 100644 --- a/frontend/rust-lib/lib-log/Cargo.toml +++ b/frontend/rust-lib/lib-log/Cargo.toml @@ -7,15 +7,13 @@ edition = "2018" [dependencies] -tracing-log = { version = "0.1.3"} -tracing-subscriber = { version = "0.2.25", features = ["registry", "env-filter", "ansi", "json"] } -tracing-bunyan-formatter = "0.2.6" -tracing-appender = "0.1" +tracing-subscriber = { version = "0.3.17", features = ["registry", "env-filter", "ansi", "json"] } +tracing-bunyan-formatter = "0.3.9" +tracing-appender = "0.2.2" tracing-core = "0.1" -tracing = { version = "0.1", features = ["log"] } -log = "0.4.17" -serde_json = "1.0" -serde = "1.0" +tracing.workspace = true +serde_json.workspace = true +serde.workspace = true chrono = "0.4" lazy_static = "1.4.0" diff --git a/frontend/rust-lib/lib-log/src/layer.rs b/frontend/rust-lib/lib-log/src/layer.rs index 870223c0cf0c..b8db7aeb54ca 100644 --- a/frontend/rust-lib/lib-log/src/layer.rs +++ b/frontend/rust-lib/lib-log/src/layer.rs @@ -4,7 +4,7 @@ use serde::ser::{SerializeMap, Serializer}; use serde_json::Value; use tracing::{Event, Id, Subscriber}; use tracing_bunyan_formatter::JsonStorage; -use tracing_core::{metadata::Level, span::Attributes}; +use tracing_core::metadata::Level; use tracing_subscriber::{fmt::MakeWriter, layer::Context, registry::SpanRef, Layer}; const LEVEL: &str = "level"; @@ -17,17 +17,22 @@ const LOG_TARGET_PATH: &str = "log.target"; const RESERVED_FIELDS: [&str; 3] = [LEVEL, TIME, MESSAGE]; const IGNORE_FIELDS: [&str; 2] = [LOG_MODULE_PATH, LOG_TARGET_PATH]; -pub struct FlowyFormattingLayer<W: MakeWriter + 'static> { +pub struct FlowyFormattingLayer<'a, W: MakeWriter<'static> + 'static> { make_writer: W, with_target: bool, + phantom: std::marker::PhantomData<&'a ()>, } -impl<W: MakeWriter + 'static> FlowyFormattingLayer<W> { +impl<'a, W> FlowyFormattingLayer<'a, W> +where + W: for<'writer> MakeWriter<'writer> + 'static, +{ #[allow(dead_code)] pub fn new(make_writer: W) -> Self { Self { make_writer, with_target: false, + phantom: std::marker::PhantomData, } } @@ -43,9 +48,9 @@ impl<W: MakeWriter + 'static> FlowyFormattingLayer<W> { Ok(()) } - fn serialize_span<S: Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>>( + fn serialize_span<S: Subscriber + for<'b> tracing_subscriber::registry::LookupSpan<'b>>( &self, - span: &SpanRef<S>, + span: &SpanRef<'a, S>, ty: Type, ctx: &Context<'_, S>, ) -> Result<Vec<u8>, std::io::Error> { @@ -86,6 +91,7 @@ impl<W: MakeWriter + 'static> FlowyFormattingLayer<W> { /// The type of record we are dealing with: entering a span, exiting a span, an /// event. +#[allow(dead_code)] #[derive(Clone, Debug)] pub enum Type { EnterSpan, @@ -104,8 +110,8 @@ impl fmt::Display for Type { } } -fn format_span_context<S: Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>>( - span: &SpanRef<S>, +fn format_span_context<'b, S: Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>>( + span: &SpanRef<'b, S>, ty: Type, context: &Context<'_, S>, ) -> String { @@ -153,10 +159,10 @@ fn format_event_message<S: Subscriber + for<'a> tracing_subscriber::registry::Lo message } -impl<S, W> Layer<S> for FlowyFormattingLayer<W> +impl<S, W> Layer<S> for FlowyFormattingLayer<'static, W> where S: Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>, - W: MakeWriter + 'static, + W: for<'writer> MakeWriter<'writer> + 'static, { fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) { // Events do not necessarily happen in the context of a span, hence @@ -221,13 +227,6 @@ where } } - fn new_span(&self, _attrs: &Attributes, id: &Id, ctx: Context<'_, S>) { - let span = ctx.span(id).expect("Span not found, this is a bug"); - if let Ok(serialized) = self.serialize_span(&span, Type::EnterSpan, &ctx) { - let _ = self.emit(serialized); - } - } - fn on_close(&self, id: Id, ctx: Context<'_, S>) { let span = ctx.span(&id).expect("Span not found, this is a bug"); if let Ok(serialized) = self.serialize_span(&span, Type::ExitSpan, &ctx) { diff --git a/frontend/rust-lib/lib-log/src/lib.rs b/frontend/rust-lib/lib-log/src/lib.rs index 86203e6378ef..ac714da02a2e 100644 --- a/frontend/rust-lib/lib-log/src/lib.rs +++ b/frontend/rust-lib/lib-log/src/lib.rs @@ -1,16 +1,17 @@ use std::sync::RwLock; +use chrono::Local; use lazy_static::lazy_static; -use log::LevelFilter; use tracing::subscriber::set_global_default; use tracing_appender::{non_blocking::WorkerGuard, rolling::RollingFileAppender}; use tracing_bunyan_formatter::JsonStorageLayer; -use tracing_log::LogTracer; +use tracing_subscriber::fmt::format::Writer; use tracing_subscriber::{layer::SubscriberExt, EnvFilter}; use crate::layer::FlowyFormattingLayer; mod layer; + lazy_static! { static ref LOG_GUARD: RwLock<Option<WorkerGuard>> = RwLock::new(None); } @@ -24,13 +25,10 @@ pub struct Builder { impl Builder { pub fn new(name: &str, directory: &str) -> Self { - // let directory = directory.as_ref().to_str().unwrap().to_owned(); - let local_file_name = format!("{}.log", name); - Builder { name: name.to_owned(), env_filter: "Info".to_owned(), - file_appender: tracing_appender::rolling::daily(directory, local_file_name), + file_appender: tracing_appender::rolling::daily(directory, format!("{}", name)), } } @@ -44,51 +42,28 @@ impl Builder { let (non_blocking, guard) = tracing_appender::non_blocking(self.file_appender); let subscriber = tracing_subscriber::fmt() + .with_timer(CustomTime) .with_ansi(true) - .with_target(true) + .with_target(false) .with_max_level(tracing::Level::TRACE) - .with_writer(std::io::stderr) - .with_thread_ids(true) - .json() - .with_current_span(true) - .with_span_list(true) - .compact() + .with_thread_ids(false) + .with_writer(std::io::stdout) + .pretty() + .with_env_filter(env_filter) .finish() - .with(env_filter) .with(JsonStorageLayer) - .with(FlowyFormattingLayer::new(std::io::stdout)) .with(FlowyFormattingLayer::new(non_blocking)); set_global_default(subscriber).map_err(|e| format!("{:?}", e))?; - LogTracer::builder() - .with_max_level(LevelFilter::Trace) - .init() - .map_err(|e| format!("{:?}", e))?; *LOG_GUARD.write().unwrap() = Some(guard); Ok(()) } } -#[cfg(test)] -mod tests { - use super::*; - - // run cargo test --features="use_bunyan" or cargo test - #[test] - fn test_log() { - Builder::new("flowy", ".") - .env_filter("debug") - .build() - .unwrap(); - tracing::info!("😁 tracing::info call"); - log::debug!("😁 log::debug call"); - - say("hello world"); - } - - #[tracing::instrument(level = "trace", name = "say")] - fn say(s: &str) { - tracing::info!("{}", s); +struct CustomTime; +impl tracing_subscriber::fmt::time::FormatTime for CustomTime { + fn format_time(&self, w: &mut Writer<'_>) -> std::fmt::Result { + write!(w, "{}", Local::now().format("%Y-%m-%d %H:%M:%S")) } } diff --git a/frontend/scripts/code_generation/freezed/generate_freezed.sh b/frontend/scripts/code_generation/freezed/generate_freezed.sh index 24e4e55fa3bc..1cf5f0fe497e 100755 --- a/frontend/scripts/code_generation/freezed/generate_freezed.sh +++ b/frontend/scripts/code_generation/freezed/generate_freezed.sh @@ -4,7 +4,7 @@ no_pub_get=false while getopts 's' flag; do case "${flag}" in - s) no_pub_get=true ;; + s) no_pub_get=true ;; esac done @@ -23,7 +23,7 @@ if [ "$no_pub_get" = false ]; then flutter packages pub get >/dev/null 2>&1 fi -dart run build_runner clean && dart run build_runner build -d +dart run build_runner build -d echo "Done generating files for appflowy_flutter" echo "Generating files for packages" @@ -39,7 +39,7 @@ for d in */; do if [ "$no_pub_get" = false ]; then flutter packages pub get >/dev/null 2>&1 fi - dart run build_runner clean && dart run build_runner build -d + dart run build_runner build -d echo "Done running build command in $d" else echo "No pubspec.yaml found in $d, it can\'t be a Dart project. Skipping." diff --git a/frontend/scripts/docker-buildfiles/Dockerfile b/frontend/scripts/docker-buildfiles/Dockerfile index eecf5fd48596..15a4d2bd58b7 100644 --- a/frontend/scripts/docker-buildfiles/Dockerfile +++ b/frontend/scripts/docker-buildfiles/Dockerfile @@ -39,7 +39,7 @@ RUN source ~/.cargo/env && \ RUN sudo pacman -S --noconfirm git tar gtk3 RUN curl -sSfL \ --output flutter.tar.xz \ - https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.10.1-stable.tar.xz && \ + https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.13.9-stable.tar.xz && \ tar -xf flutter.tar.xz && \ rm flutter.tar.xz RUN flutter config --enable-linux-desktop diff --git a/frontend/scripts/install_dev_env/install_ios.sh b/frontend/scripts/install_dev_env/install_ios.sh index bffa905a76e3..5fc820d5b91a 100644 --- a/frontend/scripts/install_dev_env/install_ios.sh +++ b/frontend/scripts/install_dev_env/install_ios.sh @@ -44,9 +44,9 @@ printMessage "Setting up Flutter" # Get the current Flutter version FLUTTER_VERSION=$(flutter --version | grep -oE 'Flutter [^ ]+' | grep -oE '[^ ]+$') -# Check if the current version is 3.10.1 -if [ "$FLUTTER_VERSION" = "3.10.1" ]; then - echo "Flutter version is already 3.10.1" +# Check if the current version is 3.13.9 +if [ "$FLUTTER_VERSION" = "3.13.9" ]; then + echo "Flutter version is already 3.13.9" else # Get the path to the Flutter SDK FLUTTER_PATH=$(which flutter) @@ -55,12 +55,12 @@ else current_dir=$(pwd) cd $FLUTTER_PATH - # Use git to checkout version 3.10.1 of Flutter - git checkout 3.10.1 + # Use git to checkout version 3.13.9 of Flutter + git checkout 3.13.9 # Get back to current working directory cd "$current_dir" - echo "Switched to Flutter version 3.10.1" + echo "Switched to Flutter version 3.13.9" fi # Enable linux desktop diff --git a/frontend/scripts/install_dev_env/install_linux.sh b/frontend/scripts/install_dev_env/install_linux.sh index ae874e6bb8fc..b7de2dbd64f1 100755 --- a/frontend/scripts/install_dev_env/install_linux.sh +++ b/frontend/scripts/install_dev_env/install_linux.sh @@ -38,9 +38,9 @@ fi printMessage "Setting up Flutter" # Get the current Flutter version FLUTTER_VERSION=$(flutter --version | grep -oP 'Flutter \K\S+') -# Check if the current version is 3.10.1 -if [ "$FLUTTER_VERSION" = "3.10.1" ]; then - echo "Flutter version is already 3.10.1" +# Check if the current version is 3.13.9 +if [ "$FLUTTER_VERSION" = "3.13.9" ]; then + echo "Flutter version is already 3.13.9" else # Get the path to the Flutter SDK FLUTTER_PATH=$(which flutter) @@ -49,12 +49,12 @@ else current_dir=$(pwd) cd $FLUTTER_PATH - # Use git to checkout version 3.10.1 of Flutter - git checkout 3.10.1 + # Use git to checkout version 3.13.9 of Flutter + git checkout 3.13.9 # Get back to current working directory cd "$current_dir" - echo "Switched to Flutter version 3.10.1" + echo "Switched to Flutter version 3.13.9" fi # Enable linux desktop diff --git a/frontend/scripts/install_dev_env/install_macos.sh b/frontend/scripts/install_dev_env/install_macos.sh index c9cf2c6ccec2..32cb6c1af47b 100755 --- a/frontend/scripts/install_dev_env/install_macos.sh +++ b/frontend/scripts/install_dev_env/install_macos.sh @@ -41,9 +41,9 @@ printMessage "Setting up Flutter" # Get the current Flutter version FLUTTER_VERSION=$(flutter --version | grep -oE 'Flutter [^ ]+' | grep -oE '[^ ]+$') -# Check if the current version is 3.10.1 -if [ "$FLUTTER_VERSION" = "3.10.1" ]; then - echo "Flutter version is already 3.10.1" +# Check if the current version is 3.13.9 +if [ "$FLUTTER_VERSION" = "3.13.9" ]; then + echo "Flutter version is already 3.13.9" else # Get the path to the Flutter SDK FLUTTER_PATH=$(which flutter) @@ -52,12 +52,12 @@ else current_dir=$(pwd) cd $FLUTTER_PATH - # Use git to checkout version 3.10.1 of Flutter - git checkout 3.10.1 + # Use git to checkout version 3.13.9 of Flutter + git checkout 3.13.9 # Get back to current working directory cd "$current_dir" - echo "Switched to Flutter version 3.10.1" + echo "Switched to Flutter version 3.13.9" fi # Enable linux desktop diff --git a/frontend/scripts/install_dev_env/install_windows.sh b/frontend/scripts/install_dev_env/install_windows.sh index cda76f9af04d..b04eeb7a78aa 100644 --- a/frontend/scripts/install_dev_env/install_windows.sh +++ b/frontend/scripts/install_dev_env/install_windows.sh @@ -48,9 +48,9 @@ fi printMessage "Setting up Flutter" # Get the current Flutter version FLUTTER_VERSION=$(flutter --version | grep -oP 'Flutter \K\S+') -# Check if the current version is 3.10.1 -if [ "$FLUTTER_VERSION" = "3.10.1" ]; then - echo "Flutter version is already 3.10.1" +# Check if the current version is 3.13.9 +if [ "$FLUTTER_VERSION" = "3.13.9" ]; then + echo "Flutter version is already 3.13.9" else # Get the path to the Flutter SDK FLUTTER_PATH=$(which flutter) @@ -59,12 +59,12 @@ else current_dir=$(pwd) cd $FLUTTER_PATH - # Use git to checkout version 3.10.1 of Flutter - git checkout 3.10.1 + # Use git to checkout version 3.13.9 of Flutter + git checkout 3.13.9 # Get back to current working directory cd "$current_dir" - echo "Switched to Flutter version 3.10.1" + echo "Switched to Flutter version 3.13.9" fi # Add pub cache and cargo to PATH diff --git a/frontend/scripts/makefile/desktop.toml b/frontend/scripts/makefile/desktop.toml index d45e4da2d028..ad408fbeb49f 100644 --- a/frontend/scripts/makefile/desktop.toml +++ b/frontend/scripts/makefile/desktop.toml @@ -66,7 +66,7 @@ script = [ cd rust-lib/ rustup show echo RUSTFLAGS="-C target-cpu=native -C link-arg=-mmacosx-version-min=11.0" cargo build --package=dart-ffi --target ${RUST_COMPILE_TARGET} --features "${FLUTTER_DESKTOP_FEATURES}" - RUSTFLAGS="-C target-cpu=native -C link-arg=-mmacosx-version-min=11.0" cargo build --package=dart-ffi --target ${RUST_COMPILE_TARGET} --features "${FLUTTER_DESKTOP_FEATURES}" + RUSTFLAGS="--cfg tokio_unstable" cargo build --package=dart-ffi --target ${RUST_COMPILE_TARGET} --features "${FLUTTER_DESKTOP_FEATURES}" cd ../ """, ] diff --git a/shared-lib/Cargo.lock b/shared-lib/Cargo.lock index 0550d668de43..fff37673fdfc 100644 --- a/shared-lib/Cargo.lock +++ b/shared-lib/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + [[package]] name = "aho-corasick" version = "0.7.18" @@ -37,19 +52,19 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.71" +version = "1.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8" +checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" [[package]] name = "async-trait" -version = "0.1.64" +version = "0.1.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cd7fce9ba8c3c042128ce72d8b2ddbf3a05747efb67ea0313c635e10bda47a2" +checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.39", ] [[package]] @@ -58,6 +73,21 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + [[package]] name = "basic-toml" version = "0.1.2" @@ -415,7 +445,6 @@ dependencies = [ "flowy-ast", "flowy-codegen", "lazy_static", - "log", "proc-macro2", "quote", "serde_json", @@ -469,6 +498,12 @@ dependencies = [ "wasi 0.10.0+wasi-snapshot-preview1", ] +[[package]] +name = "gimli" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" + [[package]] name = "glob" version = "0.3.1" @@ -627,9 +662,9 @@ dependencies = [ [[package]] name = "itoa" -version = "0.4.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" +checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" [[package]] name = "js-sys" @@ -669,7 +704,6 @@ dependencies = [ "indexmap", "indextree", "lazy_static", - "log", "serde", "serde_json", "strum", @@ -680,9 +714,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.139" +version = "0.2.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" +checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" [[package]] name = "libm" @@ -735,16 +769,24 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + [[package]] name = "mio" -version = "0.8.6" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" +checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" dependencies = [ "libc", - "log", "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.45.0", + "windows-sys 0.48.0", ] [[package]] @@ -766,6 +808,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.17.1" @@ -850,7 +901,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.16", + "syn 2.0.39", ] [[package]] @@ -964,14 +1015,14 @@ checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" dependencies = [ "proc-macro2", "quote", - "syn 2.0.16", + "syn 2.0.39", ] [[package]] name = "pin-project-lite" -version = "0.2.7" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d31d11c69a6b52a174b42bdc0c30e5e11670f90788b2c471c31c1d17d449443" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" [[package]] name = "ppv-lite86" @@ -1107,9 +1158,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.27" +version = "1.0.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f4f29d145265ec1c483c7c654450edde0bfe043d3938d6972630663356d9500" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" dependencies = [ "proc-macro2", ] @@ -1230,6 +1281,12 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5996294f19bd3aae0453a862ad728f60e6600695733dd5df01da90c54363a3c" +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + [[package]] name = "rustix" version = "0.37.3" @@ -1273,29 +1330,29 @@ checksum = "ddccb15bcce173023b3fedd9436f882a0739b8dfb45e4f6b6002bee5929f61b2" [[package]] name = "serde" -version = "1.0.163" +version = "1.0.192" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2113ab51b87a539ae008b5c6c02dc020ffa39afd2d83cffcb3f4eb2722cebec2" +checksum = "bca2a08484b285dcb282d0f67b26cadc0df8b19f8c12502c13d966bf9482f001" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.163" +version = "1.0.192" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c805777e3930c8883389c602315a24224bcc738b63905ef87cd1420353ea93e" +checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.16", + "syn 2.0.39", ] [[package]] name = "serde_json" -version = "1.0.71" +version = "1.0.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063bf466a64011ac24040a49009724ee60a57da1b437617ceb32e53ad61bfb19" +checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" dependencies = [ "itoa", "ryu", @@ -1351,12 +1408,12 @@ checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309" [[package]] name = "socket2" -version = "0.4.7" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02e2d2db9033d13a1567121ddd7a095ee144db4e1ca1b1bda3419bc0da294ebd" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" dependencies = [ "libc", - "winapi", + "windows-sys 0.48.0", ] [[package]] @@ -1390,9 +1447,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.16" +version = "2.0.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6f671d4b5ffdb8eadec19c0ae67fe2639df8684bd7bc4b83d986b8db549cf01" +checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" dependencies = [ "proc-macro2", "quote", @@ -1471,7 +1528,7 @@ checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.16", + "syn 2.0.39", ] [[package]] @@ -1485,14 +1542,13 @@ dependencies = [ [[package]] name = "tokio" -version = "1.26.0" +version = "1.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03201d01c3c27a29c8a5cee5b55a93ddae1ccf6f08f65365c2c918f8c1b76f64" +checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9" dependencies = [ - "autocfg", + "backtrace", "bytes", "libc", - "memchr", "mio", "num_cpus", "parking_lot", @@ -1500,18 +1556,18 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.45.0", + "windows-sys 0.48.0", ] [[package]] name = "tokio-macros" -version = "1.8.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d266c00fde287f55d3f1c3e96c500c362a2b8c695076ec180f27918820bc6df8" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.39", ] [[package]] @@ -1525,12 +1581,10 @@ dependencies = [ [[package]] name = "tracing" -version = "0.1.29" +version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375a639232caf30edfc78e8d89b2d4c375515393e7af7e16f01cd96917fb2105" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ - "cfg-if", - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -1538,22 +1592,22 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.18" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f480b8f81512e825f337ad51e94c1eb5d3bbdf2b363dcd01e2b19a9ffe3f8e" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.39", ] [[package]] name = "tracing-core" -version = "0.1.21" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f4ed65637b8390770814083d20756f87bfa2c21bf2f110babdc5438351746e4" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ - "lazy_static", + "once_cell", ] [[package]] diff --git a/shared-lib/Cargo.toml b/shared-lib/Cargo.toml index 82438fe2e41d..37d51ffeb2a6 100644 --- a/shared-lib/Cargo.toml +++ b/shared-lib/Cargo.toml @@ -11,3 +11,13 @@ members = [ opt-level = 0 #https://doc.rust-lang.org/rustc/codegen-options/index.html#debug-assertions #split-debuginfo = "unpacked" + + +[workspace.dependencies] +anyhow = "1.0.75" +tracing = "0.1.40" +serde = "1.0.108" +serde_json = "1.0.108" +tokio = "1.34.0" +async-trait = "0.1.74" +chrono = { version = "0.4.31", default-features = false, features = ["clock"] } diff --git a/shared-lib/flowy-codegen/Cargo.toml b/shared-lib/flowy-codegen/Cargo.toml index 6121de6edd5c..4b713071a369 100644 --- a/shared-lib/flowy-codegen/Cargo.toml +++ b/shared-lib/flowy-codegen/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" [dependencies] log = "0.4.17" serde = { version = "1.0", features = ["derive"]} -serde_json = "1.0" +serde_json.workspace = true flowy-ast = { path = "../flowy-ast"} quote = "1.0" @@ -27,7 +27,6 @@ protoc-bin-vendored = { version = "3.0", optional = true } toml = {version = "0.5.11", optional = true} - [features] proto_gen = [ "similar", diff --git a/shared-lib/flowy-codegen/src/dart_event/dart_event.rs b/shared-lib/flowy-codegen/src/dart_event/dart_event.rs index 3a0a69240b71..8ab6d3fb593d 100644 --- a/shared-lib/flowy-codegen/src/dart_event/dart_event.rs +++ b/shared-lib/flowy-codegen/src/dart_event/dart_event.rs @@ -1,22 +1,26 @@ -use super::event_template::*; -use crate::ast::EventASTContext; -use crate::flowy_toml::{parse_crate_config_from, CrateConfig}; -use crate::util::{is_crate_dir, is_hidden, path_string_with_component, read_file}; -use flowy_ast::ASTResult; use std::fs::File; use std::io::Write; use std::path::PathBuf; + use syn::Item; use walkdir::WalkDir; +use flowy_ast::ASTResult; + +use crate::ast::EventASTContext; +use crate::flowy_toml::{parse_crate_config_from, CrateConfig}; +use crate::util::{is_crate_dir, is_hidden, path_string_with_component, read_file}; + +use super::event_template::*; + pub fn gen(crate_name: &str) { if std::env::var("CARGO_MAKE_WORKING_DIRECTORY").is_err() { - log::warn!("CARGO_MAKE_WORKING_DIRECTORY was not set, skip generate dart pb"); + println!("CARGO_MAKE_WORKING_DIRECTORY was not set, skip generate dart pb"); return; } if std::env::var("FLUTTER_FLOWY_SDK_PATH").is_err() { - log::warn!("FLUTTER_FLOWY_SDK_PATH was not set, skip generate dart pb"); + println!("FLUTTER_FLOWY_SDK_PATH was not set, skip generate dart pb"); return; } diff --git a/shared-lib/flowy-codegen/src/ts_event/event_template.tera b/shared-lib/flowy-codegen/src/ts_event/event_template.tera index 2b23f787683a..46e79c0dfe1b 100644 --- a/shared-lib/flowy-codegen/src/ts_event/event_template.tera +++ b/shared-lib/flowy-codegen/src/ts_event/event_template.tera @@ -24,10 +24,8 @@ export async function {{ event_func_name }}(): Promise<Result<{{ output_deserial if (result.code == 0) { {%- if has_output %} let object = {{ output_deserializer }}.deserializeBinary(result.payload); - console.log({{ event_func_name }}.name, object); return Ok(object); {%- else %} - console.log({{ event_func_name }}.name); return Ok.EMPTY; {%- endif %} } else { diff --git a/shared-lib/flowy-derive/Cargo.toml b/shared-lib/flowy-derive/Cargo.toml index a363dbc95224..2c2285987dd9 100644 --- a/shared-lib/flowy-derive/Cargo.toml +++ b/shared-lib/flowy-derive/Cargo.toml @@ -21,10 +21,9 @@ flowy-ast = { path = "../flowy-ast" } lazy_static = {version = "1.4.0"} dashmap = "5" flowy-codegen = { path = "../flowy-codegen"} -serde_json = "1.0" +serde_json.workspace = true walkdir = "2.3.2" [dev-dependencies] -tokio = { version = "1.26", features = ["full"] } +tokio = { workspace = true, features = ["full"] } trybuild = "1.0.77" -log = "0.4.17" diff --git a/shared-lib/lib-infra/Cargo.toml b/shared-lib/lib-infra/Cargo.toml index e064f142682a..4a1c4df90c74 100644 --- a/shared-lib/lib-infra/Cargo.toml +++ b/shared-lib/lib-infra/Cargo.toml @@ -6,12 +6,12 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -chrono = { version = "0.4.31", default-features = false, features = ["clock"] } +chrono = { workspace = true, default-features = false, features = ["clock"] } bytes = { version = "1.5" } pin-project = "1.1.3" futures-core = { version = "0.3" } -tokio = { version = "1.26", features = ["time", "rt"] } +tokio = { workspace = true, features = ["time", "rt"] } rand = "0.8.5" -async-trait = "0.1.64" +async-trait.workspace = true md5 = "0.7.0" -anyhow = "1.0" +anyhow.workspace = true diff --git a/shared-lib/lib-ot/Cargo.toml b/shared-lib/lib-ot/Cargo.toml index 259cf1bdd547..9d36af88d702 100644 --- a/shared-lib/lib-ot/Cargo.toml +++ b/shared-lib/lib-ot/Cargo.toml @@ -8,10 +8,9 @@ edition = "2018" [dependencies] serde = { version = "1.0", features = ["derive", "rc"] } thiserror = "1.0" -serde_json = { version = "1.0" } +serde_json.workspace = true indexmap = {version = "1.9.2", features = ["serde"]} -log = "0.4" -tracing = { version = "0.1", features = ["log"] } +tracing.workspace = true lazy_static = "1.4.0" strum = "0.21" strum_macros = "0.21"